Compare commits
1 Commits
e44f71a60d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c5bfc223f |
46
.env.example
46
.env.example
@@ -1,42 +1,12 @@
|
|||||||
# Direkter PostgreSQL-Zugang. Die Anwendung spricht künftig unmittelbar mit
|
# Public: safe to expose to the browser (inlined into the client bundle at
|
||||||
# der Datenbank statt über eine API-Schicht — damit läuft sie auf jedem
|
# build time). Anon-key access is still fully gated by RLS server-side.
|
||||||
# PostgreSQL ab 15 (Azure Flexible Server, RDS, Cloud SQL, eigenes Blech).
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
#
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
# Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der
|
|
||||||
# Sitzungskontext, sollen die Policies nichts zurückgeben statt alles.
|
|
||||||
#
|
|
||||||
# Hinter einem Verbindungspooler (Supabase Supavisor, PgBouncer) den
|
|
||||||
# TRANSAKTIONS-Modus nehmen, nicht den Sitzungs-Modus — bei Supabase Port
|
|
||||||
# 6543 statt 5432. Jede Abfrage dieser Anwendung läuft ohnehin in einer
|
|
||||||
# Transaktion, und der Sitzungskontext wird transaktionslokal gesetzt; beides
|
|
||||||
# passt genau dazu. Der Sitzungs-Modus belegt dagegen je Client eine feste
|
|
||||||
# Verbindung und ist bei Supabase auf 15 begrenzt — danach antwortet die
|
|
||||||
# Anwendung nur noch mit „max clients reached".
|
|
||||||
DATABASE_URL=
|
|
||||||
# Auf "false" setzen, wenn die Datenbank ohne TLS läuft (lokal, CI).
|
|
||||||
DATABASE_SSL=
|
|
||||||
# Verbindungen im Pool; Vorgabe 10.
|
|
||||||
DATABASE_POOL_MAX=
|
|
||||||
|
|
||||||
# ── Anmeldung (Auth.js + Microsoft Entra ID) ─────────────────────────
|
# Server-only: bypasses Row Level Security entirely. Never prefix with
|
||||||
# Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird.
|
# NEXT_PUBLIC_, never import outside lib/supabase/admin.ts (guarded by
|
||||||
# Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel
|
# `import "server-only"`), never log or return in an API response.
|
||||||
# meldet alle ab — was im Ernstfall genau das gewünschte Mittel ist.
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
AUTH_SECRET=
|
|
||||||
|
|
||||||
# Aus der Anwendungsregistrierung im Entra-Portal: Anwendungs-ID (Client),
|
|
||||||
# ein Geheimnis daraus, und der Aussteller mit der Verzeichnis-ID (Mandant).
|
|
||||||
#
|
|
||||||
# Der Aussteller darf NICHT auf /common/ stehen bleiben — sonst könnte sich
|
|
||||||
# jedes Microsoft-Konto anmelden, auch ein privates.
|
|
||||||
AUTH_MICROSOFT_ENTRA_ID_ID=
|
|
||||||
AUTH_MICROSOFT_ENTRA_ID_SECRET=
|
|
||||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/<verzeichnis-id>/v2.0
|
|
||||||
|
|
||||||
# Nur nötig, wenn die Anwendung hinter einem Reverse Proxy unter einer
|
|
||||||
# anderen Adresse erreichbar ist, als sie selbst sieht. Ohne diesen Wert baut
|
|
||||||
# Auth.js die Rückruf-Adresse aus den Request-Headern.
|
|
||||||
AUTH_URL=
|
|
||||||
|
|
||||||
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
|
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
|
||||||
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
|
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
|
||||||
|
|||||||
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -68,23 +68,16 @@ jobs:
|
|||||||
- name: Supabase starten
|
- name: Supabase starten
|
||||||
run: supabase start
|
run: supabase start
|
||||||
|
|
||||||
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY / DB_URL; the app
|
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY; the app expects
|
||||||
# expects them under its own names. DATABASE_URL ist der direkte
|
# them under its own names.
|
||||||
# Postgres-Zugang — den braucht die neue Zugriffsschicht (lib/db) und
|
|
||||||
# vor allem der Nachweis zum Sitzungskontext.
|
|
||||||
- name: Testumgebung schreiben
|
- name: Testumgebung schreiben
|
||||||
run: |
|
run: |
|
||||||
supabase status -o env \
|
supabase status -o env \
|
||||||
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
|
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
|
||||||
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \
|
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \
|
||||||
--override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \
|
--override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \
|
||||||
--override-name db.url=DATABASE_URL \
|
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY)=' \
|
||||||
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL)=' \
|
|
||||||
| tr -d '"' > .env.test.local
|
| tr -d '"' > .env.test.local
|
||||||
# Lokal läuft Postgres ohne TLS; ohne das versucht `pg` es trotzdem.
|
|
||||||
echo "DATABASE_SSL=false" >> .env.test.local
|
|
||||||
grep -q '^DATABASE_URL=' .env.test.local \
|
|
||||||
|| { echo "DATABASE_URL wurde nicht geschrieben — der Sitzungskontext-Nachweis liefe ins Leere."; exit 1; }
|
|
||||||
|
|
||||||
- name: Seed
|
- name: Seed
|
||||||
run: node --env-file=.env.test.local supabase/seed.ts
|
run: node --env-file=.env.test.local supabase/seed.ts
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -54,6 +54,3 @@ next-env.d.ts
|
|||||||
# seeded DB — can carry HR data or use SUPABASE_SERVICE_ROLE_KEY; never commit)
|
# seeded DB — can carry HR data or use SUPABASE_SERVICE_ROLE_KEY; never commit)
|
||||||
.scratch_*
|
.scratch_*
|
||||||
/.scratch_shots/
|
/.scratch_shots/
|
||||||
|
|
||||||
# Datenbank-Sicherungen (enthalten Personendaten) – nie committen.
|
|
||||||
.backups/
|
|
||||||
|
|||||||
@@ -37,22 +37,21 @@ Werte eintragen:
|
|||||||
|
|
||||||
| Variable | Woher |
|
| Variable | Woher |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben; hinter einem Pooler den **Transaktions-Modus** (bei Supabase Port 6543) |
|
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase-Projekt → Settings → API |
|
||||||
| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft |
|
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase-Projekt → Settings → API |
|
||||||
| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` |
|
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase-Projekt → Settings → API (geheim!) |
|
||||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht |
|
|
||||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) |
|
|
||||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<verzeichnis-id>/v2.0` |
|
|
||||||
| `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` |
|
| `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` |
|
||||||
|
|
||||||
Details zur Entra-Registrierung: [`docs/entra-sso.md`](docs/entra-sso.md).
|
|
||||||
|
|
||||||
Wichtig zum Verständnis:
|
Wichtig zum Verständnis:
|
||||||
|
|
||||||
- **Nichts davon wird in das Image eingebacken.** Es gibt keine
|
- `NEXT_PUBLIC_*`-Variablen werden **beim Build** in das Browser-Bundle
|
||||||
`NEXT_PUBLIC_*`-Variablen mehr; alle Werte liest die Anwendung zur Laufzeit
|
eingebacken (Next.js-Verhalten, nicht Docker-spezifisch). Ändern sich
|
||||||
über `env_file`. Eine Änderung braucht deshalb nur einen Neustart, keinen
|
diese Werte, muss das Image **neu gebaut** werden – ein reiner Container-
|
||||||
neuen Build — und dasselbe Image läuft in Test und Produktion.
|
Neustart reicht nicht.
|
||||||
|
- `SUPABASE_SERVICE_ROLE_KEY` und `CRON_SECRET` sind Server-only-Secrets.
|
||||||
|
Sie werden bewusst **nicht** als Build-Arg übergeben (das würde sie im
|
||||||
|
Image-Layer-History sichtbar machen), sondern erst zur Laufzeit über
|
||||||
|
`env_file` injiziert.
|
||||||
- `.env` steht schon in `.gitignore` – nicht committen.
|
- `.env` steht schon in `.gitignore` – nicht committen.
|
||||||
|
|
||||||
## 2. Bauen und lokal testen
|
## 2. Bauen und lokal testen
|
||||||
@@ -146,22 +145,12 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
|
|||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- **Anmeldung endet auf `/login?error=…`:** die Umleitungs-URI in der
|
- **Login-Redirect-Loop / `proxy.ts` verhält sich falsch:** meist falsche
|
||||||
Entra-Registrierung muss exakt
|
`NEXT_PUBLIC_SUPABASE_URL`/`ANON_KEY` – Image neu bauen (siehe oben, diese
|
||||||
`https://<host>/api/auth/callback/microsoft-entra-id` lauten. Steht die
|
Werte sind eingebacken).
|
||||||
Anwendung hinter einem Reverse Proxy unter einer anderen Adresse, als sie
|
|
||||||
selbst sieht, zusätzlich `AUTH_URL` setzen.
|
|
||||||
- **Angemeldet, aber sofort zurück auf `/login?error=no_hr_access`:** die
|
|
||||||
Anmeldung hat funktioniert, es fehlt die Freischaltung. Es braucht eine
|
|
||||||
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` auf derselben
|
|
||||||
Kennung, die in `app_users` steht.
|
|
||||||
- **Cron läuft nicht:** `docker compose logs cron` – prüft, ob
|
- **Cron läuft nicht:** `docker compose logs cron` – prüft, ob
|
||||||
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
|
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
|
||||||
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
|
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
|
||||||
- **`max clients reached in session mode`:** der Verbindungsstring zeigt auf
|
- **Healthcheck rot:** `docker compose logs app` – meist fehlende/falsche
|
||||||
den Sitzungs-Modus des Poolers. Auf den Transaktions-Modus wechseln (bei
|
Supabase-Env-Variablen zur Laufzeit (`SUPABASE_SERVICE_ROLE_KEY`,
|
||||||
Supabase Port 6543).
|
Server-Komponenten).
|
||||||
- **Healthcheck rot:** `docker compose logs app` – meist `DATABASE_URL`
|
|
||||||
fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim
|
|
||||||
ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im
|
|
||||||
Start-Log.
|
|
||||||
|
|||||||
14
Dockerfile
14
Dockerfile
@@ -12,13 +12,13 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Keine Build-Argumente mehr: es gibt keine NEXT_PUBLIC_*-Werte mehr, die in
|
# Public env vars are inlined into the client bundle at build time, so they
|
||||||
# das Browser-Bundle eingebacken würden. Datenbank und Anmeldung sprechen
|
# must be available here, not just at runtime. Values are passed in via
|
||||||
# ausschliesslich den Server an, und dessen Zugangsdaten kommen zur Laufzeit.
|
# --build-arg (see DEPLOYMENT.md).
|
||||||
#
|
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||||
# Dadurch ist dieses Abbild umgebungsneutral: einmal gebaut, in Test und
|
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
# Produktion dasselbe. Vorher hätte jede Umgebung ihr eigenes gebraucht — und
|
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||||
# eine Baustrecke, die die Zugangsdaten schon zum Bauen kennt.
|
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Manner HR Master
|
# Alpenwerk HR Master
|
||||||
|
|
||||||
Interne HR-Stammdatenverwaltung: Mitarbeiter:innen, Organisationsstruktur
|
Interne HR-Stammdatenverwaltung: Mitarbeiter:innen, Organisationsstruktur
|
||||||
(Bereich/Abteilung/Team), Planstellen, Neueinstellungen, Versetzungen/
|
(Bereich/Abteilung/Team), Planstellen, Neueinstellungen, Versetzungen/
|
||||||
@@ -54,18 +54,14 @@ Liste. Kurzfassung:
|
|||||||
|
|
||||||
| Variable | Sichtbarkeit | Zweck |
|
| Variable | Sichtbarkeit | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `DATABASE_URL` | Nur Server | PostgreSQL-Verbindung. Die Rolle darf **kein** `BYPASSRLS` haben |
|
| `NEXT_PUBLIC_SUPABASE_URL` | Browser + Server | Supabase-Projekt-URL |
|
||||||
| `DATABASE_SSL` | Nur Server | `false` für lokal/CI ohne TLS |
|
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser + Server | Anon-Key, RLS-gebunden |
|
||||||
| `AUTH_SECRET` | Nur Server | Signiert und verschlüsselt das Sitzungscookie |
|
| `SUPABASE_SERVICE_ROLE_KEY` | **Nur Server** | Umgeht RLS vollständig — niemals im Browser-Bundle, niemals loggen |
|
||||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Nur Server | Anwendungs-ID der Entra-Registrierung |
|
|
||||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Nur Server | Client-Geheimnis dazu |
|
|
||||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | Nur Server | Aussteller mit Mandanten-ID — nicht `common` |
|
|
||||||
| `CRON_SECRET` | Nur Server | Schützt `/api/cron/apply-pending-changes` |
|
| `CRON_SECRET` | Nur Server | Schützt `/api/cron/apply-pending-changes` |
|
||||||
|
|
||||||
**Es gibt keine `NEXT_PUBLIC_*`-Variablen mehr.** Nichts wird in das
|
`NEXT_PUBLIC_*`-Werte werden beim Build in das Client-Bundle eingebacken —
|
||||||
Browser-Bundle eingebacken, weil der Browser mit nichts ausser der Anwendung
|
eine Änderung erfordert einen Rebuild, nicht nur einen Neustart (relevant
|
||||||
selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
|
für Docker-Deployments, siehe unten).
|
||||||
überall dasselbe — vorher brauchte jede Umgebung ihr eigenes.
|
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
|
||||||
@@ -89,15 +85,10 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
|
|||||||
- **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`,
|
- **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`,
|
||||||
geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem —
|
geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem —
|
||||||
siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell).
|
siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell).
|
||||||
- **Es gibt keinen privilegierten Zugang mehr.** Der Dienstschlüssel, der RLS
|
- **Service-Role-Key ist server-only.** Einzige Verwendung:
|
||||||
aushebelte, ist ersatzlos entfallen; auch der nächtliche Lauf benutzt
|
`lib/supabase/admin.ts`, geschützt durch `import "server-only"` (macht
|
||||||
dieselbe Rolle ohne `BYPASSRLS`. Was ohne angemeldete Person laufen muss,
|
einen versehentlichen Client-Import zu einem Build-Fehler statt einem
|
||||||
steht als `SECURITY DEFINER`-Funktion in der Datenbank und prüft dort
|
Laufzeitproblem).
|
||||||
selbst, was es tut.
|
|
||||||
- **Jede Abfrage läuft in einer Transaktion mit gesetztem Sitzungskontext.**
|
|
||||||
Die Kysely-Instanz wird nicht exportiert — der einzige Weg an die Datenbank
|
|
||||||
ist `withUser()` (`lib/db/index.ts`), und eine ESLint-Regel verbietet den
|
|
||||||
Import von `pg` ausserhalb von `lib/db/`.
|
|
||||||
- **Audit-Log ist transaktional in der Datenbank**, nicht im App-Code: jede
|
- **Audit-Log ist transaktional in der Datenbank**, nicht im App-Code: jede
|
||||||
mutierende SQL-Funktion schreibt ihren `audit_log`-Eintrag in derselben
|
mutierende SQL-Funktion schreibt ihren `audit_log`-Eintrag in derselben
|
||||||
Transaktion wie die Änderung selbst. Details und Prüfung siehe
|
Transaktion wie die Änderung selbst. Details und Prüfung siehe
|
||||||
|
|||||||
@@ -1,22 +1,24 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { signIn, signOut } from "@/auth";
|
import { redirect } from "next/navigation";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
// Anmeldung ausschliesslich über Entra ID. Es gibt bewusst keinen
|
export async function login(formData: FormData) {
|
||||||
// Passwort-Pfad: ein zweiter Anmeldeweg neben dem Firmenkonto hebelt jede
|
const email = String(formData.get("email") ?? "");
|
||||||
// Vorgabe des Mandanten aus — Mehrfaktor, bedingten Zugriff, Sperrung beim
|
const password = String(formData.get("password") ?? "");
|
||||||
// Austritt.
|
|
||||||
//
|
|
||||||
// Die Herkunft muss hier nicht mehr aus dem Request geholt werden: Auth.js
|
|
||||||
// baut die Rückruf-Adresse selbst und akzeptiert nur Ziele auf demselben
|
|
||||||
// Host. Ein untergeschobener Host läuft also weiterhin ins Leere.
|
|
||||||
|
|
||||||
export async function signInWithEntra() {
|
const supabase = await createClient();
|
||||||
// Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in
|
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
// Next.js.
|
|
||||||
await signIn("microsoft-entra-id", { redirectTo: "/" });
|
if (error) {
|
||||||
|
redirect("/login?error=invalid_credentials");
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
await signOut({ redirectTo: "/login" });
|
const supabase = await createClient();
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||||
import { withUser } from "@/lib/db";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
|
import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||||
import type { CollectiveAgreement, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
|
||||||
|
type ActionResult = { success: boolean; error?: string };
|
||||||
|
type MutationFn = keyof Database["public"]["Functions"];
|
||||||
|
|
||||||
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
||||||
const result = await runMutation(await currentUserId(), fn, payload);
|
const supabase = await createClient();
|
||||||
if (!result.success) return result;
|
const { error } = await supabase.rpc(fn, { payload });
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
for (const path of revalidate) revalidatePath(path);
|
for (const path of revalidate) revalidatePath(path);
|
||||||
return result;
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hireEmployee(payload: {
|
export async function hireEmployee(payload: {
|
||||||
@@ -21,15 +24,6 @@ export async function hireEmployee(payload: {
|
|||||||
gender: "m" | "w";
|
gender: "m" | "w";
|
||||||
birth_date: string;
|
birth_date: string;
|
||||||
sv_nummer?: string;
|
sv_nummer?: string;
|
||||||
/**
|
|
||||||
* Pflicht, weil employees.email NOT NULL ist.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
email: string;
|
|
||||||
phone?: string;
|
phone?: string;
|
||||||
position_id?: string;
|
position_id?: string;
|
||||||
team_id?: string;
|
team_id?: string;
|
||||||
@@ -50,19 +44,13 @@ export async function hireEmployee(payload: {
|
|||||||
is_laterale_fuehrung?: boolean;
|
is_laterale_fuehrung?: boolean;
|
||||||
is_c_level?: boolean;
|
is_c_level?: boolean;
|
||||||
}): Promise<ActionResult & { employeeId?: string }> {
|
}): Promise<ActionResult & { employeeId?: string }> {
|
||||||
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
|
const supabase = await createClient();
|
||||||
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
|
const { data, error } = await supabase.rpc("hire_employee", { payload });
|
||||||
try {
|
if (error) return { success: false, error: error.message };
|
||||||
const employeeId = await withUser(await currentUserId(), (tx) =>
|
|
||||||
callFunction(tx, "hire_employee", payload as Record<string, unknown>)
|
|
||||||
);
|
|
||||||
revalidatePath("/employees");
|
revalidatePath("/employees");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
revalidatePath("/positions");
|
revalidatePath("/positions");
|
||||||
return { success: true, employeeId: employeeId as string };
|
return { success: true, employeeId: data as string };
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function terminateEmployee(payload: {
|
export async function terminateEmployee(payload: {
|
||||||
@@ -77,8 +65,8 @@ export async function terminateEmployee(payload: {
|
|||||||
export async function transferEmployee(payload: {
|
export async function transferEmployee(payload: {
|
||||||
employee_id: string;
|
employee_id: string;
|
||||||
effective_date: string;
|
effective_date: string;
|
||||||
/** Die Zielplanstelle; Bereich, Abteilung und Team ergeben sich aus ihrer Einheit. */
|
new_team_id: string;
|
||||||
target_position_id: string;
|
new_title?: string;
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||||
}
|
}
|
||||||
@@ -165,3 +153,23 @@ export async function addEmployeeNote(payload: {
|
|||||||
export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise<ActionResult> {
|
export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise<ActionResult> {
|
||||||
return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
|
return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EmployeeSearchResult = { id: string; first_name: string; last_name: string; job_title: string; team_id: string | null };
|
||||||
|
|
||||||
|
// Shared by "Position besetzen" (staff an open position) and the reorg
|
||||||
|
// workbench's "Mitarbeiter:in(nen)" multi-select — both search active/
|
||||||
|
// on-leave employees by name or title.
|
||||||
|
export async function searchActiveEmployees(query: string): Promise<EmployeeSearchResult[]> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
let q = supabase
|
||||||
|
.from("employees")
|
||||||
|
.select("id, first_name, last_name, job_title, team_id")
|
||||||
|
.in("status", ["Aktiv", "Karenz"])
|
||||||
|
.limit(20);
|
||||||
|
if (query.trim()) {
|
||||||
|
const term = sanitizeIlikeTerm(query.trim());
|
||||||
|
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||||||
|
}
|
||||||
|
const { data } = await q;
|
||||||
|
return data ?? [];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,52 +1,45 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { withUser } from "@/lib/db";
|
|
||||||
import type { ActionResult } from "@/lib/db/rpc";
|
type ActionResult = { success: boolean; error?: string };
|
||||||
|
|
||||||
export async function saveHireDraft(payload: {
|
export async function saveHireDraft(payload: {
|
||||||
id?: string;
|
id?: string;
|
||||||
step: number;
|
step: number;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}): Promise<ActionResult & { id?: string }> {
|
}): Promise<ActionResult & { id?: string }> {
|
||||||
const userId = await currentUserId();
|
const supabase = await createClient();
|
||||||
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) return { success: false, error: "Nicht angemeldet." };
|
||||||
|
|
||||||
try {
|
|
||||||
const id = await withUser(userId, async (tx) => {
|
|
||||||
if (payload.id) {
|
if (payload.id) {
|
||||||
// Ob die Zeile der aufrufenden Person gehört, entscheidet die
|
const { error } = await supabase
|
||||||
// Policy hire_drafts_owner — nicht eine Prüfung hier.
|
.from("hire_drafts")
|
||||||
await tx
|
.update({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
||||||
.updateTable("hire_drafts")
|
.eq("id", payload.id);
|
||||||
.set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
if (error) return { success: false, error: error.message };
|
||||||
.where("id", "=", payload.id)
|
|
||||||
.execute();
|
|
||||||
return payload.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = await tx
|
|
||||||
.insertInto("hire_drafts")
|
|
||||||
.values({ created_by: userId, step: payload.step, payload: payload.data })
|
|
||||||
.returning("id")
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
return row.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return { success: true, id };
|
return { success: true, id: payload.id };
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("hire_drafts")
|
||||||
|
.insert({ created_by: user.id, step: payload.step, payload: payload.data })
|
||||||
|
.select("id")
|
||||||
|
.single();
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
|
revalidatePath("/");
|
||||||
|
return { success: true, id: data.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteHireDraft(id: string): Promise<ActionResult> {
|
export async function deleteHireDraft(id: string): Promise<ActionResult> {
|
||||||
try {
|
const supabase = await createClient();
|
||||||
await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute());
|
const { error } = await supabase.from("hire_drafts").delete().eq("id", id);
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||||
import { runMutation, type ActionResult } from "@/lib/db/rpc";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
type ActionResult = { success: boolean; error?: string };
|
||||||
|
|
||||||
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
||||||
|
|
||||||
@@ -11,16 +13,18 @@ async function callRpc(
|
|||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
revalidate: string[]
|
revalidate: string[]
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
const result = await runMutation(await currentUserId(), fn, payload);
|
const supabase = await createClient();
|
||||||
if (!result.success) return result;
|
const { error } = await supabase.rpc(fn, { payload });
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
for (const path of revalidate) revalidatePath(path);
|
for (const path of revalidate) revalidatePath(path);
|
||||||
return result;
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPosition(payload: {
|
export async function createPosition(payload: {
|
||||||
org_unit_id: string;
|
title: string;
|
||||||
job_title: string;
|
superior_employee_id: string;
|
||||||
is_chief: boolean;
|
is_lead: boolean;
|
||||||
|
team_id?: string;
|
||||||
valid_from: string;
|
valid_from: string;
|
||||||
}): Promise<ActionResult> {
|
}): Promise<ActionResult> {
|
||||||
return callRpc("create_position", payload, POSITION_PATHS);
|
return callRpc("create_position", payload, POSITION_PATHS);
|
||||||
@@ -30,3 +34,23 @@ export async function deletePosition(positionId: string): Promise<ActionResult>
|
|||||||
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
|
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string };
|
||||||
|
|
||||||
|
// For "Position ausschreiben": superior lookup, filtered to team-leads when
|
||||||
|
// the new position is an IC role, or to division-heads/CEO when the new
|
||||||
|
// position is itself a team lead (§2).
|
||||||
|
export async function searchSuperiors(query: string, forLeadPosition: boolean): Promise<SuperiorSearchResult[]> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
let q = supabase
|
||||||
|
.from("employees")
|
||||||
|
.select("id, first_name, last_name, job_title, division_id")
|
||||||
|
.eq("status", "Aktiv")
|
||||||
|
.limit(20);
|
||||||
|
q = forLeadPosition ? q.lte("org_level", 1) : q.eq("is_lead", true).eq("org_level", 2);
|
||||||
|
if (query.trim()) {
|
||||||
|
const term = sanitizeIlikeTerm(query.trim());
|
||||||
|
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||||||
|
}
|
||||||
|
const { data } = await q;
|
||||||
|
return data ?? [];
|
||||||
|
}
|
||||||
|
|||||||
37
actions/reorg.ts
Normal file
37
actions/reorg.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
type ActionResult = { success: boolean; error?: string };
|
||||||
|
|
||||||
|
export type ReorgMovePayload = {
|
||||||
|
kind: "emp" | "team" | "abt" | "dept";
|
||||||
|
label: string;
|
||||||
|
employee_ids: string[];
|
||||||
|
target_team_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function applyReorg(payload: {
|
||||||
|
name: string;
|
||||||
|
effective_date: string;
|
||||||
|
moves: ReorgMovePayload[];
|
||||||
|
}): Promise<ActionResult & { scenarioId?: string }> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase.rpc("apply_reorg", { payload });
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
|
revalidatePath("/orgchart");
|
||||||
|
revalidatePath("/employees");
|
||||||
|
revalidatePath("/");
|
||||||
|
return { success: true, scenarioId: data as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function undoReorg(payload: { scenario_id: string }): Promise<ActionResult> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.rpc("undo_reorg", { payload });
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
|
revalidatePath("/orgchart");
|
||||||
|
revalidatePath("/employees");
|
||||||
|
revalidatePath("/");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -1,31 +1,27 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { withUser } from "@/lib/db";
|
|
||||||
import type { ActionResult } from "@/lib/db/rpc";
|
type ActionResult = { success: boolean; error?: string };
|
||||||
|
|
||||||
export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> {
|
export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> {
|
||||||
const userId = await currentUserId();
|
const supabase = await createClient();
|
||||||
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) return { success: false, error: "Nicht angemeldet." };
|
||||||
|
|
||||||
try {
|
const { error } = await supabase.from("saved_reports").insert({ created_by: user.id, name: payload.name, config: payload.config });
|
||||||
await withUser(userId, (tx) =>
|
if (error) return { success: false, error: error.message };
|
||||||
tx.insertInto("saved_reports").values({ created_by: userId, name: payload.name, config: payload.config }).execute()
|
|
||||||
);
|
|
||||||
revalidatePath("/reports");
|
revalidatePath("/reports");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteReport(id: string): Promise<ActionResult> {
|
export async function deleteReport(id: string): Promise<ActionResult> {
|
||||||
try {
|
const supabase = await createClient();
|
||||||
await withUser(await currentUserId(), (tx) => tx.deleteFrom("saved_reports").where("id", "=", id).execute());
|
const { error } = await supabase.from("saved_reports").delete().eq("id", id);
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
revalidatePath("/reports");
|
revalidatePath("/reports");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (err) {
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { AuditDetail } from "@/components/audit/AuditDetail";
|
|
||||||
import { AuditFilters } from "@/components/audit/AuditFilters";
|
import { AuditFilters } from "@/components/audit/AuditFilters";
|
||||||
import { CARD_CLASS } from "@/components/ui/Card";
|
import { CARD_CLASS } from "@/components/ui/Card";
|
||||||
import { Pagination } from "@/components/ui/Pagination";
|
import { Pagination } from "@/components/ui/Pagination";
|
||||||
import { actionBadgeStyle } from "@/lib/colors";
|
import { actionBadgeStyle } from "@/lib/colors";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||||
import { withUser } from "@/lib/db";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -35,50 +34,33 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
|||||||
|
|
||||||
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||||||
|
const from = (page - 1) * PAGE_SIZE;
|
||||||
|
const to = from + PAGE_SIZE - 1;
|
||||||
|
|
||||||
const { entries, count } = await withUser(await currentUserId(), async (tx) => {
|
let query = supabase
|
||||||
const base = () => {
|
.from("audit_log")
|
||||||
let q = tx.selectFrom("audit_log");
|
.select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" })
|
||||||
if (params.action) q = q.where("action", "=", params.action);
|
.order("occurred_at", { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (params.action) query = query.eq("action", params.action);
|
||||||
if (params.q) {
|
if (params.q) {
|
||||||
// Als Parameter gebunden statt in die Abfrage geschrieben: die
|
const q = sanitizeIlikeTerm(params.q.trim());
|
||||||
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, haben
|
query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`);
|
||||||
// hier keine Bedeutung mehr.
|
|
||||||
const like = `%${params.q.trim()}%`;
|
|
||||||
q = q.where((eb) =>
|
|
||||||
eb.or([eb("target_label", "ilike", like), eb("details", "ilike", like), eb("actor_name", "ilike", like)])
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return q;
|
|
||||||
};
|
|
||||||
|
|
||||||
const [entries, total] = await Promise.all([
|
const { data: entries, count } = await query;
|
||||||
base()
|
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
||||||
.select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details", "changes"])
|
|
||||||
// Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die
|
|
||||||
// Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten
|
|
||||||
// erscheinen oder auf keiner.
|
|
||||||
.orderBy("occurred_at", "desc")
|
|
||||||
.orderBy("id", "desc")
|
|
||||||
.limit(PAGE_SIZE)
|
|
||||||
.offset((page - 1) * PAGE_SIZE)
|
|
||||||
.execute(),
|
|
||||||
base()
|
|
||||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
|
||||||
.executeTakeFirst(),
|
|
||||||
]);
|
|
||||||
return { entries, count: Number(total?.anzahl ?? 0) };
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<AuditFilters />
|
<AuditFilters />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<p className="text-sm text-ink-muted">{count} Einträge</p>
|
<p className="text-sm text-ink-muted">{count ?? 0} Einträge</p>
|
||||||
|
|
||||||
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||||
<table className="w-full min-w-[800px] text-sm">
|
<table className="w-full min-w-[800px] text-sm">
|
||||||
@@ -92,7 +74,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((entry) => {
|
{(entries ?? []).map((entry) => {
|
||||||
return (
|
return (
|
||||||
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
||||||
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-ink-body">
|
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-ink-body">
|
||||||
@@ -116,13 +98,11 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
|||||||
entry.target_label
|
entry.target_label
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-4 py-2.5 text-ink-muted">{entry.details ?? "–"}</td>
|
||||||
<AuditDetail eintrag={entry} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{entries.length === 0 && (
|
{(entries ?? []).length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
|
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
|
||||||
Keine Einträge gefunden.
|
Keine Einträge gefunden.
|
||||||
|
|||||||
@@ -1,108 +1,74 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { withUser } from "@/lib/db";
|
import type { Database } from "@/lib/supabase/types";
|
||||||
import { todayIso } from "@/lib/format";
|
|
||||||
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
|
|
||||||
import { loadPlacements, loadReportingLines } from "@/lib/placement";
|
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
|
||||||
|
|
||||||
type PageProps = { params: Promise<{ id: string }> };
|
type PageProps = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
type EmployeeWithManager = Database["public"]["Tables"]["employees"]["Row"] & {
|
||||||
|
manager: { id: string; first_name: string; last_name: string; job_title: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
export default async function EmployeeDetailPage({ params }: PageProps) {
|
export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const today = todayIso();
|
const supabase = await createClient();
|
||||||
|
|
||||||
const data = await withUser(await currentUserId(), async (tx) => {
|
// Everything here keys off the id already in the URL, and the manager
|
||||||
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
|
// comes back as an embedded resource on the employee row rather than as a
|
||||||
// kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der
|
// follow-up query — so the page is one round trip instead of two. Measured
|
||||||
// Funktion ein, es wandern also neun Zeilen über die Leitung und nicht
|
// against the hosted database that halved the data time (120ms -> 62ms,
|
||||||
// achthundert.
|
// median of five), because a round trip costs more than these queries do.
|
||||||
const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] =
|
//
|
||||||
await Promise.all([
|
// The hand-written Database type carries no relationship metadata
|
||||||
tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(),
|
// (NoRelationships), so the embed is typed at the destructure below.
|
||||||
loadReportingLines(tx, today, { employeeId: id }),
|
const [
|
||||||
loadReportingLines(tx, today, { actingManagerId: id }),
|
{ data: employeeRow },
|
||||||
tx
|
{ data: directReports },
|
||||||
.selectFrom("employee_history")
|
{ data: history },
|
||||||
.selectAll()
|
{ data: dependents },
|
||||||
.where("employee_id", "=", id)
|
{ data: notes },
|
||||||
.orderBy("event_date", "desc")
|
{ data: divisions },
|
||||||
.orderBy("created_at", "desc")
|
{ data: departments },
|
||||||
.execute(),
|
{ data: teams },
|
||||||
tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(),
|
{ data: locations },
|
||||||
tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(),
|
{ data: openPositions },
|
||||||
loadOrgMaps(tx),
|
] = await Promise.all([
|
||||||
loadPlacements(tx, { asOf: today, employeeIds: [id] }),
|
supabase.from("employees").select("*, manager:manager_id(id, first_name, last_name, job_title)").eq("id", id).single(),
|
||||||
loadOpenPositions(tx),
|
supabase.from("employees").select("id, first_name, last_name, job_title, status").eq("manager_id", id).order("last_name"),
|
||||||
|
supabase
|
||||||
|
.from("employee_history")
|
||||||
|
.select("*")
|
||||||
|
.eq("employee_id", id)
|
||||||
|
.order("event_date", { ascending: false })
|
||||||
|
.order("created_at", { ascending: false }),
|
||||||
|
supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"),
|
||||||
|
supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }),
|
||||||
|
supabase.from("divisions").select("*").order("name"),
|
||||||
|
supabase.from("departments").select("*"),
|
||||||
|
supabase.from("teams").select("*"),
|
||||||
|
supabase.from("locations").select("*").order("name"),
|
||||||
|
supabase.from("positions").select("id, position_number, title, team_id, is_lead").eq("status", "open"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!employee) return null;
|
if (!employeeRow) notFound();
|
||||||
const line = ownLines[0] ?? null;
|
|
||||||
|
|
||||||
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
// Split the embedded manager back off so EmployeeDetail keeps receiving a
|
||||||
// formal zuständige Leitung und die direkten Berichte.
|
// plain employees row plus a separate manager, unchanged.
|
||||||
const relatedIds = Array.from(
|
const { manager, ...employee } = employeeRow as EmployeeWithManager;
|
||||||
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])),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!data) notFound();
|
|
||||||
const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployeeDetail
|
<EmployeeDetail
|
||||||
employee={employee}
|
employee={employee}
|
||||||
placement={
|
manager={manager ?? null}
|
||||||
placement && {
|
directReports={directReports ?? []}
|
||||||
positionNumber: placement.positionNumber,
|
|
||||||
jobTitle: placement.jobTitle,
|
|
||||||
isChief: placement.isChief,
|
|
||||||
current: placement.current,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
|
|
||||||
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
|
|
||||||
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
|
|
||||||
// zweimal da.
|
|
||||||
formalManager={
|
|
||||||
line?.formal_manager_id && line.formal_manager_id !== line.acting_manager_id
|
|
||||||
? (byId.get(line.formal_manager_id) ?? null)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
directReports={reports.flatMap((r) => {
|
|
||||||
const e = byId.get(r.employee_id);
|
|
||||||
return e ? [e] : [];
|
|
||||||
})}
|
|
||||||
history={history ?? []}
|
history={history ?? []}
|
||||||
dependents={dependents ?? []}
|
dependents={dependents ?? []}
|
||||||
notes={notes ?? []}
|
notes={notes ?? []}
|
||||||
locations={orgMaps.locationList}
|
divisions={divisions ?? []}
|
||||||
openPositions={openPositions}
|
departments={departments ?? []}
|
||||||
|
teams={teams ?? []}
|
||||||
|
locations={locations ?? []}
|
||||||
|
openPositions={openPositions ?? []}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,11 @@ import { Avatar } from "@/components/ui/Avatar";
|
|||||||
import { CARD_CLASS } from "@/components/ui/Card";
|
import { CARD_CLASS } from "@/components/ui/Card";
|
||||||
import { Pagination } from "@/components/ui/Pagination";
|
import { Pagination } from "@/components/ui/Pagination";
|
||||||
import { StatusChip } from "@/components/ui/StatusChip";
|
import { StatusChip } from "@/components/ui/StatusChip";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
|
||||||
import { withUser } from "@/lib/db";
|
|
||||||
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
|
||||||
import { fmtDate, todayIso } from "@/lib/format";
|
import { fmtDate, todayIso } from "@/lib/format";
|
||||||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
|
import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
|
||||||
import { loadPlacements } from "@/lib/placement";
|
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 15;
|
const PAGE_SIZE = 15;
|
||||||
@@ -33,115 +32,53 @@ function pageHref(params: SearchParams, page: number): string {
|
|||||||
|
|
||||||
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
const supabase = await createClient();
|
||||||
const today = todayIso();
|
|
||||||
|
|
||||||
|
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||||||
|
const from = (page - 1) * PAGE_SIZE;
|
||||||
|
const to = from + PAGE_SIZE - 1;
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.from("employees")
|
||||||
|
.select(
|
||||||
|
"id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status, absence_type",
|
||||||
|
{ count: "exact" }
|
||||||
|
)
|
||||||
|
.order("last_name", { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (params.q) {
|
||||||
|
const q = params.q.trim();
|
||||||
|
if (/^\d+$/.test(q)) {
|
||||||
|
query = query.eq("personnel_number", Number(q));
|
||||||
|
} else {
|
||||||
|
const term = sanitizeIlikeTerm(q);
|
||||||
|
query = query.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (params.division) query = query.eq("division_id", params.division);
|
||||||
// Comma-separated, so a dashboard tile can link here with the same
|
// Comma-separated, so a dashboard tile can link here with the same
|
||||||
// status set it counted rather than a narrower one.
|
// status set it counted rather than a narrower one.
|
||||||
const statuses = (params.status ?? "")
|
const statuses = (params.status ?? "")
|
||||||
.split(",")
|
.split(",")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
||||||
|
|
||||||
const { orgMaps, employees, count, placements } = await withUser(await currentUserId(), async (tx) => {
|
|
||||||
// Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum.
|
|
||||||
// „Produktion" meint die Abteilungen und Teams darunter — in der Einheit
|
|
||||||
// selbst sitzt nur die Bereichsleitung.
|
|
||||||
const orgMaps = await loadOrgMaps(tx);
|
|
||||||
const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null;
|
|
||||||
|
|
||||||
// Eine Filterkette, zwei Abfragen: eine für die Seite, eine für die
|
|
||||||
// Gesamtzahl. Am direkten Zugang teilen sie sich denselben Aufbau —
|
|
||||||
// vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der
|
|
||||||
// API-Schicht einen bedingt zusammengesetzten Select-String nicht
|
|
||||||
// auflösen konnte.
|
|
||||||
const base = () => {
|
|
||||||
let q = tx.selectFrom("employees");
|
|
||||||
|
|
||||||
if (unitFilter) {
|
|
||||||
// Nach Organisationseinheit gefiltert wird über die *laufende*
|
|
||||||
// Besetzung. Als EXISTS, damit eine Person nicht mehrfach erscheint,
|
|
||||||
// wenn sie über die Zeit mehrere Zuordnungen hatte.
|
|
||||||
const units = subtreeOf(orgMaps, unitFilter);
|
|
||||||
q = q.where((eb) =>
|
|
||||||
eb.exists(
|
|
||||||
eb
|
|
||||||
.selectFrom("position_assignments as a")
|
|
||||||
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
|
||||||
.select("a.id")
|
|
||||||
.whereRef("a.employee_id", "=", "employees.id")
|
|
||||||
.where("a.valid_to", "is", null)
|
|
||||||
.where("p.org_unit_id", "in", units)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.q) {
|
|
||||||
const term = params.q.trim();
|
|
||||||
if (/^d+$/.test(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}%`;
|
|
||||||
q = q.where((eb) =>
|
|
||||||
eb.or([eb("first_name", "ilike", like), eb("last_name", "ilike", like), eb("job_title", "ilike", like)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derived from the dates, not read off employees.status — see
|
// Derived from the dates, not read off employees.status — see
|
||||||
// lib/employee-status-filter.ts for why the two can disagree.
|
// lib/employee-status-filter.ts for why the two can disagree.
|
||||||
if (statuses.length > 0) {
|
query = applyDerivedStatusFilter(query, statuses, todayIso());
|
||||||
q = q.where((eb) => derivedStatusFilter(eb, statuses, today) ?? eb.val(true));
|
if (params.location) query = query.eq("location_id", params.location);
|
||||||
}
|
|
||||||
|
|
||||||
if (params.location) q = q.where("location_id", "=", params.location);
|
// The org lookup tables are needed only to label the rows, so they load
|
||||||
return q;
|
// alongside the page of employees instead of before it — one round trip
|
||||||
};
|
// saved on a page that is otherwise two fast queries.
|
||||||
|
const [orgMaps, { data: employeesData, count }] = await Promise.all([loadOrgMaps(supabase), query]);
|
||||||
const [rows, total] = await Promise.all([
|
const employees = employeesData ?? [];
|
||||||
base()
|
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
||||||
.select([
|
|
||||||
"id",
|
|
||||||
"first_name",
|
|
||||||
"last_name",
|
|
||||||
"personnel_number",
|
|
||||||
"job_title",
|
|
||||||
"location_id",
|
|
||||||
"entry_date",
|
|
||||||
"employment_type",
|
|
||||||
"weekly_hours",
|
|
||||||
"status",
|
|
||||||
"absence_type",
|
|
||||||
])
|
|
||||||
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
|
|
||||||
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
|
|
||||||
// Seiten erscheinen oder auf keiner.
|
|
||||||
.orderBy("last_name")
|
|
||||||
.orderBy("id")
|
|
||||||
.limit(PAGE_SIZE)
|
|
||||||
.offset((page - 1) * PAGE_SIZE)
|
|
||||||
.execute(),
|
|
||||||
base()
|
|
||||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
|
||||||
.executeTakeFirst(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 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 };
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
|
<EmployeeFilters divisions={orgMaps.divisionList} locations={orgMaps.locationList} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
|
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
|
||||||
|
|
||||||
@@ -160,9 +97,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{employees.map((e) => {
|
{employees.map((e) => {
|
||||||
const placement = placements.get(e.id);
|
const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
|
||||||
const division = divisionOf(orgMaps, placement?.orgUnitId);
|
|
||||||
const unit = unitOf(orgMaps, placement?.orgUnitId);
|
|
||||||
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
|
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
|
||||||
return (
|
return (
|
||||||
// border-subtle between rows: the full-strength border made
|
// border-subtle between rows: the full-strength border made
|
||||||
@@ -178,7 +113,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
|||||||
<div className="truncate font-semibold text-ink">
|
<div className="truncate font-semibold text-ink">
|
||||||
{e.first_name} {e.last_name}
|
{e.first_name} {e.last_name}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-xs text-ink-muted">{placement?.jobTitle ?? e.job_title}</div>
|
<div className="truncate text-xs text-ink-muted">{e.job_title}</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
@@ -187,12 +122,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
|||||||
<td className="px-4 py-2.5 tabular-nums text-ink-body">{e.personnel_number}</td>
|
<td className="px-4 py-2.5 tabular-nums text-ink-body">{e.personnel_number}</td>
|
||||||
<td className="px-4 py-2.5 text-ink-body">
|
<td className="px-4 py-2.5 text-ink-body">
|
||||||
<div>{division?.name ?? "–"}</div>
|
<div>{division?.name ?? "–"}</div>
|
||||||
{/* Die eigene Einheit, egal auf welcher Ebene sie hängt —
|
<div className="text-xs text-ink-muted">{team?.name ?? "–"}</div>
|
||||||
eine Bereichsleitung sitzt am Bereich, nicht an einem
|
|
||||||
Team, und stand vorher deshalb ohne Zuordnung da. */}
|
|
||||||
<div className="text-xs text-ink-muted" title={breadcrumbLabel(orgMaps, placement?.orgUnitId)}>
|
|
||||||
{unit && unit.id !== division?.id ? unit.name : "–"}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-ink-body">{location?.name ?? "–"}</td>
|
<td className="px-4 py-2.5 text-ink-body">{location?.name ?? "–"}</td>
|
||||||
<td className="px-4 py-2.5 tabular-nums text-ink-body">{fmtDate(e.entry_date)}</td>
|
<td className="px-4 py-2.5 tabular-nums text-ink-body">{fmtDate(e.entry_date)}</td>
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { ImportWorkbench } from "@/components/import/ImportWorkbench";
|
|
||||||
|
|
||||||
export const metadata = { title: "Import" };
|
|
||||||
|
|
||||||
export default function ImportPage() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<p className="max-w-prose text-sm text-ink-muted">
|
|
||||||
Übernahme aus einer Datei — Organisation, Planstellen, Personen, Historie und Angehörige. Angelegt wird nur;
|
|
||||||
bestehende Datensätze werden nie überschrieben. Geprüft wird vor dem Schreiben, und geschrieben wird alles
|
|
||||||
zusammen oder gar nichts.
|
|
||||||
</p>
|
|
||||||
<ImportWorkbench />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,55 +2,36 @@ import { redirect } from "next/navigation";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
||||||
import { AppShell } from "@/components/shell/AppShell";
|
import { AppShell } from "@/components/shell/AppShell";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
|
||||||
import { withUser } from "@/lib/db";
|
|
||||||
import { loadOpenNotes } from "@/lib/notes";
|
import { loadOpenNotes } from "@/lib/notes";
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
import { loadOpenPositions } from "@/lib/positions";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||||
const userId = await currentUserId();
|
const supabase = await createClient();
|
||||||
if (!userId) redirect("/login");
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
|
// Defense in depth: proxy.ts already redirects any non-active-HR session
|
||||||
// und damit nebenbei auf einem einheitlichen Lesestand.
|
// away before this layout ever renders. Re-checking here means a gap in
|
||||||
const data = await withUser(userId, async (tx) => {
|
// the proxy matcher (or a future route added outside it) still fails
|
||||||
// Hier — und nicht im Proxy — fällt die Entscheidung über den Zugang.
|
// closed instead of silently granting access — see docs/security.md.
|
||||||
// Der Proxy prüft nur, ob überhaupt jemand angemeldet ist; er hat keine
|
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle();
|
||||||
// Datenbankverbindung. Diese Abfrage läuft bei jedem Aufbau frisch, eine
|
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login");
|
||||||
// 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([
|
const userLabel = profile.full_name || profile.email || user.email || "";
|
||||||
loadOpenPositions(tx),
|
|
||||||
tx.selectFrom("locations").select(["id", "name", "country"]).orderBy("name").execute(),
|
const [openPositions, locationsRes, draftsRes, openNotes] = await Promise.all([
|
||||||
tx
|
loadOpenPositions(supabase),
|
||||||
.selectFrom("hire_drafts")
|
supabase.from("locations").select("id, name, country").order("name"),
|
||||||
.select(["id", "step", "payload", "updated_at"])
|
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
||||||
.where("created_by", "=", userId)
|
loadOpenNotes(supabase),
|
||||||
.orderBy("updated_at", "desc")
|
|
||||||
.execute(),
|
|
||||||
loadOpenNotes(tx),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { profile, openPositions, locations, drafts, openNotes };
|
|
||||||
});
|
|
||||||
|
|
||||||
// `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.
|
|
||||||
if (!data) redirect("/login?error=no_hr_access");
|
|
||||||
|
|
||||||
const userLabel = data.profile.full_name || data.profile.email || "";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
|
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
|
||||||
<AppShell userLabel={userLabel} openNotes={data.openNotes}>
|
<AppShell userLabel={userLabel} openNotes={openNotes}>
|
||||||
{children}
|
{children}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</HireWizardProvider>
|
</HireWizardProvider>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { OrgChartClient } from "@/components/orgchart/OrgChartClient";
|
import { OrgChartClient } from "@/components/orgchart/OrgChartClient";
|
||||||
import type { OrgUnitNode } from "@/components/orgchart/types";
|
|
||||||
import { todayIso } from "@/lib/format";
|
import { todayIso } from "@/lib/format";
|
||||||
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
||||||
|
import { loadOpenPositions } from "@/lib/positions";
|
||||||
import { parseIsoDateParam } from "@/lib/reports";
|
import { parseIsoDateParam } from "@/lib/reports";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { withUser } from "@/lib/db";
|
|
||||||
|
|
||||||
type SearchParams = { asOf?: string; focus?: string };
|
type SearchParams = { asOf?: string; focus?: string };
|
||||||
|
|
||||||
@@ -19,20 +18,32 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro
|
|||||||
// so a junk value can't reach the client as an arbitrary string.
|
// so a junk value can't reach the client as an arbitrary string.
|
||||||
const focusId = params.focus && UUID.test(params.focus) ? params.focus : null;
|
const focusId = params.focus && UUID.test(params.focus) ? params.focus : null;
|
||||||
|
|
||||||
const { org, units } = await withUser(await currentUserId(), async (tx) => {
|
const supabase = await createClient();
|
||||||
const [org, units] = await Promise.all([
|
|
||||||
loadOrgAsOf(tx, asOf),
|
const [org, { data: divisions }, { data: departments }, { data: teams }, openPositions, { data: reorgScenarios }] =
|
||||||
tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(),
|
await Promise.all([
|
||||||
|
loadOrgAsOf(supabase, asOf),
|
||||||
|
supabase.from("divisions").select("*").order("name"),
|
||||||
|
supabase.from("departments").select("*"),
|
||||||
|
supabase.from("teams").select("*"),
|
||||||
|
loadOpenPositions(supabase),
|
||||||
|
supabase
|
||||||
|
.from("reorg_scenarios")
|
||||||
|
.select("id, name, effective_date, applied, applied_at")
|
||||||
|
.eq("applied", true)
|
||||||
|
.order("applied_at", { ascending: false })
|
||||||
|
.limit(5),
|
||||||
]);
|
]);
|
||||||
return { org, units };
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<OrgChartClient
|
<OrgChartClient
|
||||||
employees={org.employees}
|
employees={org.employees}
|
||||||
units={units as OrgUnitNode[]}
|
divisions={divisions ?? []}
|
||||||
vacancies={org.vacancies}
|
departments={departments ?? []}
|
||||||
|
teams={teams ?? []}
|
||||||
|
openPositions={openPositions}
|
||||||
|
reorgScenarios={reorgScenarios ?? []}
|
||||||
asOf={asOf}
|
asOf={asOf}
|
||||||
today={today}
|
today={today}
|
||||||
projectedCount={org.projectedCount}
|
projectedCount={org.projectedCount}
|
||||||
|
|||||||
@@ -4,13 +4,9 @@ import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
|||||||
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
||||||
import { actionBadgeStyle } from "@/lib/colors";
|
import { actionBadgeStyle } from "@/lib/colors";
|
||||||
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
|
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
|
||||||
import { divisionOf, loadOrgMaps } from "@/lib/org";
|
|
||||||
import { loadPlacements } from "@/lib/placement";
|
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
|
||||||
import { deriveStatusAsOf } from "@/lib/reports";
|
import { deriveStatusAsOf } from "@/lib/reports";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { withUser } from "@/lib/db";
|
import { fetchAllRows } from "@/lib/supabase/query";
|
||||||
import type { HistoryEventType } from "@/lib/supabase/types";
|
|
||||||
|
|
||||||
// Each KPI carries a colour already; the accent bar repeats it in a second
|
// Each KPI carries a colour already; the accent bar repeats it in a second
|
||||||
// channel so the tiles are scannable as a row rather than six identical
|
// channel so the tiles are scannable as a row rather than six identical
|
||||||
@@ -40,6 +36,18 @@ const DOT_STYLES: Record<string, string> = {
|
|||||||
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" } as const;
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
const { data: drafts } = user
|
||||||
|
? await supabase
|
||||||
|
.from("hire_drafts")
|
||||||
|
.select("id, step, payload, updated_at")
|
||||||
|
.eq("created_by", user.id)
|
||||||
|
.order("updated_at", { ascending: false })
|
||||||
|
: { data: [] };
|
||||||
|
|
||||||
// Built as strings, not by round-tripping a local Date through
|
// Built as strings, not by round-tripping a local Date through
|
||||||
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
|
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
|
||||||
// the previous year in UTC, which shifted the whole YTD window a day early
|
// the previous year in UTC, which shifted the whole YTD window a day early
|
||||||
@@ -50,8 +58,6 @@ export default async function DashboardPage() {
|
|||||||
const yearEnd = `${year}-12-31`;
|
const yearEnd = `${year}-12-31`;
|
||||||
const in60Iso = addDaysIso(today, 60);
|
const in60Iso = addDaysIso(today, 60);
|
||||||
|
|
||||||
const userId = await currentUserId();
|
|
||||||
|
|
||||||
// Headcount, FTE, Karenz and the division bars all come from one full read
|
// Headcount, FTE, Karenz and the division bars all come from one full read
|
||||||
// and the *derived* status, not from the `employees.status` column.
|
// and the *derived* status, not from the `employees.status` column.
|
||||||
//
|
//
|
||||||
@@ -60,117 +66,69 @@ export default async function DashboardPage() {
|
|||||||
// planned hire whose start date has passed, or a Karenz that ended without
|
// planned hire whose start date has passed, or a Karenz that ended without
|
||||||
// anyone recording the return, made the dashboard and the Berichte page
|
// anyone recording the return, made the dashboard and the Berichte page
|
||||||
// disagree about the same headcount. Same derivation, same numbers.
|
// disagree about the same headcount. Same derivation, same numbers.
|
||||||
const {
|
// It also replaces four separate count queries with one.
|
||||||
drafts,
|
|
||||||
staffRows,
|
|
||||||
hiresYtd,
|
|
||||||
exitsYtd,
|
|
||||||
openPositions,
|
|
||||||
orgMaps,
|
|
||||||
placements,
|
|
||||||
upcomingHires,
|
|
||||||
upcomingExits,
|
|
||||||
upcomingReturns,
|
|
||||||
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 [
|
const [
|
||||||
drafts,
|
|
||||||
staffRows,
|
staffRows,
|
||||||
hiresYtd,
|
hiresYtdRes,
|
||||||
exitsYtd,
|
exitsYtdRes,
|
||||||
openPositions,
|
openPositionsRes,
|
||||||
orgMaps,
|
divisionsRes,
|
||||||
placements,
|
upcomingHiresRes,
|
||||||
upcomingHires,
|
upcomingExitsRes,
|
||||||
upcomingExits,
|
upcomingReturnsRes,
|
||||||
upcomingReturns,
|
historyRes,
|
||||||
history,
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
userId
|
fetchAllRows(() =>
|
||||||
? tx
|
supabase
|
||||||
.selectFrom("hire_drafts")
|
.from("employees")
|
||||||
.select(["id", "step", "payload", "updated_at"])
|
.select("weekly_hours, division_id, entry_date, exit_date, karenz_start_date, karenz_return_date")
|
||||||
.where("created_by", "=", userId)
|
.order("id")
|
||||||
.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
|
// Entries/exits count history events, which is what the linked report
|
||||||
// counts too. `entry_date` would also sweep up rehires, whose event is
|
// counts too. `entry_date` would also sweep up rehires, whose event is
|
||||||
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
||||||
// different numbers for the same year.
|
// different numbers for the same year.
|
||||||
countIn(["Eintritt", "Wiedereintritt"]),
|
supabase
|
||||||
countIn(["Austritt"]),
|
.from("employee_history")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
loadOpenPositions(tx),
|
.in("event_type", ["Eintritt", "Wiedereintritt"])
|
||||||
loadOrgMaps(tx),
|
.gte("event_date", yearStart)
|
||||||
loadPlacements(tx, { asOf: today }),
|
.lte("event_date", yearEnd),
|
||||||
|
supabase
|
||||||
tx
|
.from("employee_history")
|
||||||
.selectFrom("employees")
|
.select("id", { count: "exact", head: true })
|
||||||
.select(["id", "first_name", "last_name", "entry_date"])
|
.eq("event_type", "Austritt")
|
||||||
.where("status", "=", "Geplant")
|
.gte("event_date", yearStart)
|
||||||
.where("entry_date", ">=", today)
|
.lte("event_date", yearEnd),
|
||||||
.where("entry_date", "<=", in60Iso)
|
supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
|
||||||
.execute(),
|
supabase.from("divisions").select("id, name"),
|
||||||
|
supabase
|
||||||
tx
|
.from("employees")
|
||||||
.selectFrom("employees")
|
.select("id, first_name, last_name, entry_date")
|
||||||
.select(["id", "first_name", "last_name", "exit_date"])
|
.eq("status", "Geplant")
|
||||||
.where("exit_date", "is not", null)
|
.gte("entry_date", today)
|
||||||
.where("exit_date", ">=", today)
|
.lte("entry_date", in60Iso),
|
||||||
.where("exit_date", "<=", in60Iso)
|
supabase
|
||||||
.execute(),
|
.from("employees")
|
||||||
|
.select("id, first_name, last_name, exit_date")
|
||||||
tx
|
.not("exit_date", "is", null)
|
||||||
.selectFrom("employees")
|
.gte("exit_date", today)
|
||||||
.select(["id", "first_name", "last_name", "karenz_return_date"])
|
.lte("exit_date", in60Iso),
|
||||||
.where("status", "=", "Karenz")
|
supabase
|
||||||
.where("karenz_return_date", "is not", null)
|
.from("employees")
|
||||||
.where("karenz_return_date", ">=", today)
|
.select("id, first_name, last_name, karenz_return_date")
|
||||||
.where("karenz_return_date", "<=", in60Iso)
|
.eq("status", "Karenz")
|
||||||
.execute(),
|
.not("karenz_return_date", "is", null)
|
||||||
|
.gte("karenz_return_date", today)
|
||||||
tx
|
.lte("karenz_return_date", in60Iso),
|
||||||
.selectFrom("employee_history as h")
|
supabase
|
||||||
.leftJoin("employees as e", "e.id", "h.employee_id")
|
.from("employee_history")
|
||||||
.select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"])
|
.select("id, employee_id, event_date, event_type, description")
|
||||||
.orderBy("h.event_date", "desc")
|
.order("event_date", { ascending: false })
|
||||||
.orderBy("h.created_at", "desc")
|
.order("created_at", { ascending: false })
|
||||||
.limit(10)
|
.limit(10),
|
||||||
.execute(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
|
||||||
drafts,
|
|
||||||
staffRows,
|
|
||||||
hiresYtd: Number(hiresYtd?.anzahl ?? 0),
|
|
||||||
exitsYtd: Number(exitsYtd?.anzahl ?? 0),
|
|
||||||
openPositions,
|
|
||||||
orgMaps,
|
|
||||||
placements,
|
|
||||||
upcomingHires,
|
|
||||||
upcomingExits,
|
|
||||||
upcomingReturns,
|
|
||||||
history,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
||||||
// active, and is counted by its own tile instead. FTE follows the same
|
// active, and is counted by its own tile instead. FTE follows the same
|
||||||
// set: Karenz contributes no capacity, so including it would overstate
|
// set: Karenz contributes no capacity, so including it would overstate
|
||||||
@@ -185,37 +143,31 @@ export default async function DashboardPage() {
|
|||||||
const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length;
|
const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length;
|
||||||
const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
|
const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
|
||||||
|
|
||||||
// Der Bereich einer Person steht nicht mehr auf ihr; er ergibt sich aus der
|
|
||||||
// Einheit ihrer Planstelle und deren Vorfahren. Die Bereichsleitung selbst
|
|
||||||
// sitzt *am* Bereich, ihre Leute darunter — beide landen über die
|
|
||||||
// Vorfahrenkette im selben Balken.
|
|
||||||
const headcountByDivision = new Map<string, number>();
|
const headcountByDivision = new Map<string, number>();
|
||||||
for (const row of activeStaff) {
|
for (const row of activeStaff) {
|
||||||
const division = divisionOf(orgMaps, placements.get(row.id)?.orgUnitId);
|
if (!row.division_id) continue;
|
||||||
if (!division) continue;
|
headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
|
||||||
headcountByDivision.set(division.id, (headcountByDivision.get(division.id) ?? 0) + 1);
|
|
||||||
}
|
}
|
||||||
const divisionBars = orgMaps.unitList
|
const divisionBars = (divisionsRes.data ?? [])
|
||||||
.filter((u) => u.unit_type === "Bereich")
|
|
||||||
.map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 }))
|
.map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 }))
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
|
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
|
||||||
|
|
||||||
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
|
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
|
||||||
const upcoming: UpcomingItem[] = [
|
const upcoming: UpcomingItem[] = [
|
||||||
...(upcomingHires).map((e) => ({
|
...(upcomingHiresRes.data ?? []).map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
date: e.entry_date,
|
date: e.entry_date,
|
||||||
kind: "hire" as const,
|
kind: "hire" as const,
|
||||||
})),
|
})),
|
||||||
...(upcomingExits).map((e) => ({
|
...(upcomingExitsRes.data ?? []).map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
date: e.exit_date!,
|
date: e.exit_date!,
|
||||||
kind: "exit" as const,
|
kind: "exit" as const,
|
||||||
})),
|
})),
|
||||||
...(upcomingReturns).map((e) => ({
|
...(upcomingReturnsRes.data ?? []).map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
date: e.karenz_return_date!,
|
date: e.karenz_return_date!,
|
||||||
@@ -225,6 +177,12 @@ export default async function DashboardPage() {
|
|||||||
.sort((a, b) => a.date.localeCompare(b.date))
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
|
||||||
|
const historyEmployeesRes = historyEmployeeIds.length
|
||||||
|
? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds)
|
||||||
|
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
||||||
|
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
||||||
|
|
||||||
// Each tile links to the view that shows what it counts, with the filters
|
// Each tile links to the view that shows what it counts, with the filters
|
||||||
// pre-applied.
|
// pre-applied.
|
||||||
//
|
//
|
||||||
@@ -245,18 +203,18 @@ export default async function DashboardPage() {
|
|||||||
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
||||||
{
|
{
|
||||||
label: "Eintritte (Jahr)",
|
label: "Eintritte (Jahr)",
|
||||||
value: hiresYtd,
|
value: hiresYtdRes.count ?? 0,
|
||||||
tone: "success",
|
tone: "success",
|
||||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Austritte (Jahr)",
|
label: "Austritte (Jahr)",
|
||||||
value: exitsYtd,
|
value: exitsYtdRes.count ?? 0,
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||||
},
|
},
|
||||||
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
||||||
{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" },
|
{ label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand", href: "/positions" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -331,14 +289,14 @@ export default async function DashboardPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
||||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||||
{(history).map((h) => (
|
{(historyRes.data ?? []).map((h) => (
|
||||||
<li key={h.id} className="flex gap-2.5 py-2.5">
|
<li key={h.id} className="flex gap-2.5 py-2.5">
|
||||||
{/* Dot aligned to the first line of text, not centred on the
|
{/* Dot aligned to the first line of text, not centred on the
|
||||||
whole row, so it stays put as descriptions wrap. */}
|
whole row, so it stays put as descriptions wrap. */}
|
||||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? `${h.first_name} ${h.last_name}` : "Unbekannt"}</span>
|
<span className="text-sm font-semibold text-ink">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span>
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||||
{h.event_type}
|
{h.event_type}
|
||||||
</span>
|
</span>
|
||||||
@@ -347,7 +305,7 @@ export default async function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{(history).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,21 @@
|
|||||||
import type { UnitOption } from "@/components/positions/CreatePositionModal";
|
|
||||||
import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
|
import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
|
||||||
import { daysBetweenIso } from "@/lib/format";
|
import { daysBetweenIso, toIsoDate } from "@/lib/format";
|
||||||
import { loadOrgMaps } from "@/lib/org";
|
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
import { loadOpenPositions } from "@/lib/positions";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { withUser } from "@/lib/db";
|
|
||||||
|
|
||||||
export default async function PositionsPage() {
|
export default async function PositionsPage() {
|
||||||
const { openPositions, orgMaps, chiefRows } = await withUser(await currentUserId(), async (tx) => {
|
const supabase = await createClient();
|
||||||
const [openPositions, orgMaps, chiefRows] = await Promise.all([
|
|
||||||
loadOpenPositions(tx),
|
// Teams are only needed for the "Position ausschreiben" dialog's team
|
||||||
loadOrgMaps(tx),
|
// select. The division/department/team headcount overview this page used
|
||||||
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
|
// to render was dropped, and with it the two employee-wide aggregation
|
||||||
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
|
// queries that fed it.
|
||||||
// Fehlermeldung nach dem Absenden.
|
const [openPositions, { data: teams }] = await Promise.all([
|
||||||
tx.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null).execute(),
|
loadOpenPositions(supabase),
|
||||||
|
supabase.from("teams").select("*").order("name"),
|
||||||
]);
|
]);
|
||||||
return { openPositions, orgMaps, chiefRows };
|
|
||||||
});
|
|
||||||
|
|
||||||
const withChief = new Set(chiefRows.map((r) => r.org_unit_id));
|
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(toIsoDate(p.created_at)) }));
|
||||||
const units: UnitOption[] = orgMaps.unitList.map((u) => ({
|
|
||||||
id: u.id,
|
|
||||||
name: u.name,
|
|
||||||
unit_type: u.unit_type,
|
|
||||||
depth: orgMaps.depthOf.get(u.id) ?? 0,
|
|
||||||
hasChief: withChief.has(u.id),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) }));
|
return <PositionsPageClient openPositions={openPositionsWithDays} teams={teams ?? []} />;
|
||||||
|
|
||||||
return <PositionsPageClient openPositions={openPositionsWithDays} units={units} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import {
|
|||||||
totalForRows,
|
totalForRows,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||||
import { currentUserId } from "@/lib/auth/session";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { withUser } from "@/lib/db";
|
|
||||||
|
|
||||||
type SearchParams = {
|
type SearchParams = {
|
||||||
mode?: string;
|
mode?: string;
|
||||||
@@ -36,6 +35,7 @@ type SearchParams = {
|
|||||||
|
|
||||||
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
|
const supabase = await createClient();
|
||||||
const mode = parseMode(params.mode);
|
const mode = parseMode(params.mode);
|
||||||
|
|
||||||
// Both modes are parsed up front so the data load can start before
|
// Both modes are parsed up front so the data load can start before
|
||||||
@@ -55,17 +55,11 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
// in, so all three go out together. Against a hosted database a round trip
|
// in, so all three go out together. Against a hosted database a round trip
|
||||||
// costs about as much as the query itself, which made this page's three
|
// costs about as much as the query itself, which made this page's three
|
||||||
// sequential waves its dominant cost.
|
// sequential waves its dominant cost.
|
||||||
const userId = await currentUserId();
|
const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([
|
||||||
|
loadOrgLookups(supabase),
|
||||||
// Alles in einer Transaktion — dort gilt der Sitzungskontext, und der
|
supabase.auth.getUser(),
|
||||||
// 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([
|
|
||||||
loadOrgLookups(tx),
|
|
||||||
mode === "events"
|
mode === "events"
|
||||||
? loadEventHistory(tx, {
|
? loadEventHistory(supabase, {
|
||||||
eventType: eventType ?? undefined,
|
eventType: eventType ?? undefined,
|
||||||
division: params.division,
|
division: params.division,
|
||||||
location: params.location,
|
location: params.location,
|
||||||
@@ -74,7 +68,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
mode === "snapshot"
|
mode === "snapshot"
|
||||||
? loadSnapshotEmployees(tx, {
|
? loadSnapshotEmployees(supabase, {
|
||||||
division: params.division,
|
division: params.division,
|
||||||
location: params.location,
|
location: params.location,
|
||||||
status: params.status,
|
status: params.status,
|
||||||
@@ -82,17 +76,13 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
asOf,
|
asOf,
|
||||||
})
|
})
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
userId
|
|
||||||
? tx
|
|
||||||
.selectFrom("saved_reports")
|
|
||||||
.select(["id", "name", "config"])
|
|
||||||
.where("created_by", "=", userId)
|
|
||||||
.orderBy("created_at", "desc")
|
|
||||||
.execute()
|
|
||||||
: Promise.resolve([]),
|
|
||||||
]);
|
]);
|
||||||
return { lookups, divisions, locations, events, employees, savedReports };
|
|
||||||
});
|
const user = userRes.user;
|
||||||
|
// Still a wave of its own: it needs the user id the call above resolves.
|
||||||
|
const { data: savedReports } = user
|
||||||
|
? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false })
|
||||||
|
: { data: [] };
|
||||||
|
|
||||||
if (mode === "events") {
|
if (mode === "events") {
|
||||||
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
|
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
|
||||||
@@ -110,7 +100,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
recordCount={events.length}
|
recordCount={events.length}
|
||||||
divisions={divisions}
|
divisions={divisions}
|
||||||
locations={locations}
|
locations={locations}
|
||||||
savedReports={savedReports}
|
savedReports={savedReports ?? []}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
@@ -137,7 +127,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
recordCount={employees.length}
|
recordCount={employees.length}
|
||||||
divisions={divisions}
|
divisions={divisions}
|
||||||
locations={locations}
|
locations={locations}
|
||||||
savedReports={savedReports}
|
savedReports={savedReports ?? []}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
|
import { login, logout } from "@/actions/auth";
|
||||||
import { logout, signInWithEntra } from "@/actions/auth";
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { CONTROL_CLASS } from "@/components/ui/Field";
|
||||||
|
|
||||||
// The query string is attacker-controlled, so the login page renders a message
|
// The query string is attacker-controlled, so the login page renders a message
|
||||||
// looked up by code rather than whatever text ?error= carries. Reflecting the
|
// looked up by code rather than whatever text ?error= carries. Reflecting the
|
||||||
// raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt,
|
// raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt,
|
||||||
// rufen Sie …") on the real, correctly-branded sign-in screen. Dasselbe gilt
|
// rufen Sie …") on the real, correctly-branded sign-in screen.
|
||||||
// für die Fehlertexte, die Entra im Rückweg mitschickt.
|
|
||||||
const ERROR_MESSAGES = {
|
const ERROR_MESSAGES = {
|
||||||
no_hr_access: {
|
no_hr_access: "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
|
||||||
title: "Kein HR-Zugriff",
|
invalid_credentials: "E-Mail oder Passwort ist falsch.",
|
||||||
body: "Ihr Firmenkonto ist bekannt, aber nicht für die Personalverwaltung freigeschaltet. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
|
|
||||||
},
|
|
||||||
sso_failed: {
|
|
||||||
title: "Anmeldung fehlgeschlagen",
|
|
||||||
body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
|
||||||
},
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type ErrorCode = keyof typeof ERROR_MESSAGES;
|
type ErrorCode = keyof typeof ERROR_MESSAGES;
|
||||||
@@ -25,119 +19,60 @@ type LoginPageProps = {
|
|||||||
|
|
||||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
// Ein unbekannter Code wird nicht verschluckt, sondern auf die allgemeine
|
const code = params.error && Object.hasOwn(ERROR_MESSAGES, params.error) ? (params.error as ErrorCode) : null;
|
||||||
// Meldung abgebildet: Auth.js schickt bei einem Fehlschlag seine eigenen
|
|
||||||
// Codes („Configuration", „AccessDenied", „OAuthCallbackError" …), und ohne
|
|
||||||
// diese Abbildung stünde man vor einer Anmeldeseite, die so tut, als wäre
|
|
||||||
// nichts gewesen. Angezeigt wird trotzdem nur eigener Text — der Parameter
|
|
||||||
// selbst kommt nie auf die Seite.
|
|
||||||
const code = params.error
|
|
||||||
? Object.hasOwn(ERROR_MESSAGES, params.error)
|
|
||||||
? (params.error as ErrorCode)
|
|
||||||
: "sso_failed"
|
|
||||||
: null;
|
|
||||||
const error = code ? ERROR_MESSAGES[code] : null;
|
const error = code ? ERROR_MESSAGES[code] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte
|
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
|
||||||
// im ersten Moment unter dem Faltenrand sitzt.
|
<div className="w-full max-w-sm rounded border border-border bg-white p-8 shadow-sm">
|
||||||
<div className="min-h-dvh lg:grid lg:grid-cols-[1.05fr_1fr]">
|
<h1 className="text-xl font-extrabold text-ink">Alpenwerk HR</h1>
|
||||||
<BrandPanel />
|
<p className="mt-1 text-sm text-ink-muted">Melden Sie sich mit Ihrem Firmenkonto an.</p>
|
||||||
|
|
||||||
<main className="flex items-center justify-center px-6 py-12 lg:py-6">
|
|
||||||
<div className="w-full max-w-sm">
|
|
||||||
<div className="lg:hidden">
|
|
||||||
<Wordmark className="text-ink" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="mt-8 text-2xl font-extrabold tracking-tight text-ink lg:mt-0">Anmelden</h2>
|
|
||||||
<p className="mt-1.5 text-sm text-ink-muted">
|
|
||||||
Der Zugang läuft über Ihr Firmenkonto. Ein eigenes Passwort gibt es nicht.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div role="alert" className="mt-6 rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3">
|
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||||
<p className="text-sm font-bold text-danger-text">{error.title}</p>
|
{error}
|
||||||
<p className="mt-1 text-sm text-danger-text/90">{error.body}</p>
|
|
||||||
{code === "no_hr_access" && (
|
{code === "no_hr_access" && (
|
||||||
<form action={logout} className="mt-3">
|
<form action={logout} className="mt-2">
|
||||||
<button
|
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
|
||||||
type="submit"
|
Abmelden und mit anderem Konto versuchen
|
||||||
className="rounded text-xs font-semibold text-danger-text underline underline-offset-2 hover:no-underline
|
|
||||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger-text"
|
|
||||||
>
|
|
||||||
Abmelden und mit einem anderen Konto versuchen
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form action={signInWithEntra} className="mt-7">
|
<form action={login} className="mt-6 flex flex-col gap-4">
|
||||||
<EntraSignInButton />
|
<div>
|
||||||
</form>
|
<label htmlFor="email" className="mb-1 block text-sm font-semibold text-ink">
|
||||||
|
E-Mail
|
||||||
<p className="mt-6 border-t border-border-subtle pt-5 text-xs leading-relaxed text-ink-muted">
|
</label>
|
||||||
Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle
|
<input
|
||||||
Personaldaten verschlossen.
|
id="email"
|
||||||
</p>
|
name="email"
|
||||||
</div>
|
type="email"
|
||||||
</main>
|
required
|
||||||
</div>
|
autoComplete="username"
|
||||||
);
|
className={CONTROL_CLASS}
|
||||||
}
|
|
||||||
|
|
||||||
function BrandPanel() {
|
|
||||||
return (
|
|
||||||
<aside className="relative hidden overflow-hidden bg-brand-700 lg:flex lg:flex-col lg:justify-between lg:p-12">
|
|
||||||
{/* Zwei weiche Lichtpunkte und ein feines Raster — genug Struktur, damit
|
|
||||||
die Fläche nicht wie ein Farbfehler wirkt, aber ohne Bilddatei und
|
|
||||||
ohne von der einen Schaltfläche gegenüber abzulenken. */}
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute inset-0"
|
|
||||||
style={{
|
|
||||||
backgroundImage:
|
|
||||||
"radial-gradient(60rem 40rem at 15% 0%, rgb(255 255 255 / 0.16), transparent 60%)," +
|
|
||||||
"radial-gradient(40rem 40rem at 100% 100%, rgb(255 255 255 / 0.10), transparent 55%)," +
|
|
||||||
"linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px)," +
|
|
||||||
"linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px)",
|
|
||||||
backgroundSize: "auto, auto, 3rem 3rem, 3rem 3rem",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<Wordmark className="text-white" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<div className="relative max-w-md">
|
<label htmlFor="password" className="mb-1 block text-sm font-semibold text-ink">
|
||||||
<p className="text-3xl font-extrabold leading-tight tracking-tight text-white">
|
Passwort
|
||||||
Die Organisation, so wie sie heute wirklich aussieht.
|
</label>
|
||||||
</p>
|
<input
|
||||||
<p className="mt-4 text-sm leading-relaxed text-white/70">
|
id="password"
|
||||||
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen
|
name="password"
|
||||||
Stichtag.
|
type="password"
|
||||||
</p>
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className={CONTROL_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" fullWidth className="mt-2">
|
||||||
|
Anmelden
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="relative text-xs text-white/50">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Wordmark({ className = "" }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<div className={`flex items-center gap-2.5 ${className}`}>
|
|
||||||
{/* Vier Quadrate wie ein Organigramm-Ausschnitt: eine Wurzel, darunter
|
|
||||||
drei Einheiten. */}
|
|
||||||
<svg viewBox="0 0 24 24" className="h-7 w-7" aria-hidden="true">
|
|
||||||
<rect x="9" y="2" width="6" height="6" rx="1.5" fill="currentColor" />
|
|
||||||
<rect x="1" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
|
|
||||||
<rect x="9" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.75" />
|
|
||||||
<rect x="17" y="16" width="6" height="6" rx="1.5" fill="currentColor" opacity="0.55" />
|
|
||||||
<path d="M12 8v4M4 16v-4h16v4" stroke="currentColor" strokeWidth="1.5" fill="none" opacity="0.5" />
|
|
||||||
</svg>
|
|
||||||
<span className="text-lg font-extrabold tracking-tight">Alpenwerk HR</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { handlers } from "@/auth";
|
|
||||||
|
|
||||||
// Der Rückweg aus Entra ID und die Endpunkte für An- und Abmeldung.
|
|
||||||
//
|
|
||||||
// Tritt an die Stelle von app/auth/callback/route.ts: den Tausch des
|
|
||||||
// Einmal-Codes gegen eine Sitzung, die Prüfung von `state` und `nonce` und
|
|
||||||
// das Setzen des Cookies macht jetzt Auth.js. Die Rückruf-Adresse in der
|
|
||||||
// Entra-Anwendungsregistrierung ändert sich dadurch — siehe docs/entra-sso.md.
|
|
||||||
export const { GET, POST } = handlers;
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { asSystem } from "@/lib/db";
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
import { callFunction } from "@/lib/db/rpc";
|
|
||||||
|
|
||||||
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
|
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
|
||||||
// ändern with a future "Wirksam ab" date) once their date has arrived — see
|
// ändern with a future "Wirksam ab" date) once their date has arrived — see
|
||||||
@@ -14,15 +13,13 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
|
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kein privilegierter Zugang mehr: derselbe Datenbankbenutzer ohne
|
const supabase = createAdminClient();
|
||||||
// BYPASSRLS wie überall. apply_due_pending_changes ist SECURITY DEFINER
|
const { data, error } = await supabase.rpc("apply_due_pending_changes");
|
||||||
// und prüft selbst, was sie tut — der Dienstschlüssel, der RLS aushebelte,
|
|
||||||
// ist damit entfallen.
|
if (error) {
|
||||||
try {
|
console.error("apply_due_pending_changes failed:", error);
|
||||||
const applied = await asSystem((tx) => callFunction(tx, "apply_due_pending_changes"));
|
|
||||||
return NextResponse.json({ applied });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("apply_due_pending_changes failed:", err);
|
|
||||||
return NextResponse.json({ error: "Interner Fehler." }, { status: 500 });
|
return NextResponse.json({ error: "Interner Fehler." }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ applied: data });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,14 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { statusLabel } from "@/lib/absence";
|
import { statusLabel } from "@/lib/absence";
|
||||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||||
import { todayIso } from "@/lib/format";
|
|
||||||
import { subtreeOf } from "@/lib/org";
|
|
||||||
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
|
|
||||||
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
|
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
|
||||||
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
||||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
import { requireHrUser } from "@/lib/supabase/auth";
|
||||||
import { withUser } from "@/lib/db";
|
import { fetchAllRows } from "@/lib/supabase/query";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
|
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
|
||||||
|
|
||||||
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
|
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||||
// über die Planstelle und die abgeleitete Berichtslinie.
|
|
||||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & {
|
|
||||||
org_unit_id: string | null;
|
|
||||||
position_number: string | null;
|
|
||||||
is_chief: boolean;
|
|
||||||
manager_id: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Full raw data dump — every column on `employees`, not just the fields a
|
// Full raw data dump — every column on `employees`, not just the fields a
|
||||||
// pivot report groups by. Respects the same division/location/status/
|
// pivot report groups by. Respects the same division/location/status/
|
||||||
@@ -25,8 +16,9 @@ type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & {
|
|||||||
// filtering happens against the *derived* status as of that date rather
|
// filtering happens against the *derived* status as of that date rather
|
||||||
// than the live `status` column — see deriveStatusAsOf.
|
// than the live `status` column — see deriveStatusAsOf.
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const gate = await requireHrUser();
|
const supabase = await createClient();
|
||||||
if ("denied" in gate) return gate.denied;
|
const denied = await requireHrUser(supabase);
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const params = request.nextUrl.searchParams;
|
const params = request.nextUrl.searchParams;
|
||||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||||
@@ -40,58 +32,24 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const statuses = parseStatuses(filters.status);
|
const statuses = parseStatuses(filters.status);
|
||||||
|
|
||||||
const stichtag = asOf ?? todayIso();
|
|
||||||
|
|
||||||
const { employees, lookups, orgMaps, allEmployees, dependentsCounts, placements, lines } = await withUser(
|
|
||||||
gate.userId,
|
|
||||||
async (tx) => {
|
|
||||||
function employeeQuery() {
|
function employeeQuery() {
|
||||||
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
|
let query = supabase.from("employees").select("*").order("last_name").order("id");
|
||||||
if (filters.location) q = q.where("location_id", "=", filters.location);
|
if (filters.division) query = query.eq("division_id", filters.division);
|
||||||
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
|
if (filters.location) query = query.eq("location_id", filters.location);
|
||||||
if (!asOf) q = q.where("status", "in", statuses);
|
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
||||||
return q;
|
if (!asOf) query = query.in("status", statuses);
|
||||||
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
|
const [employees, { lookups }, allEmployees, dependentsCounts] = await Promise.all([
|
||||||
employeeQuery().execute(),
|
fetchAllRows(employeeQuery),
|
||||||
loadOrgLookups(tx),
|
loadOrgLookups(supabase),
|
||||||
tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(),
|
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
|
||||||
loadDependentsCounts(tx),
|
loadDependentsCounts(supabase),
|
||||||
loadPlacements(tx, { asOf: stichtag }),
|
|
||||||
loadReportingLineMap(tx, stichtag),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
|
||||||
employees,
|
|
||||||
lookups: lookupResult.lookups,
|
|
||||||
orgMaps: lookupResult.orgMaps,
|
|
||||||
allEmployees,
|
|
||||||
dependentsCounts,
|
|
||||||
placements,
|
|
||||||
lines,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
||||||
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
|
const rows = asOf ? employees.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : employees;
|
||||||
// Export für "Produktion" nur die Bereichsleitung.
|
|
||||||
const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
|
||||||
|
|
||||||
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 [];
|
|
||||||
return [{
|
|
||||||
...e,
|
|
||||||
org_unit_id: orgUnitId,
|
|
||||||
position_number: placement?.positionNumber ?? null,
|
|
||||||
is_chief: placement?.isChief ?? false,
|
|
||||||
manager_id: lines.get(e.id)?.acting_manager_id ?? null,
|
|
||||||
}];
|
|
||||||
});
|
|
||||||
const rows = asOf ? enriched.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : enriched;
|
|
||||||
const columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf);
|
const columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf);
|
||||||
const filename = exportFilename("mitarbeiter-export", format);
|
const filename = exportFilename("mitarbeiter-export", format);
|
||||||
|
|
||||||
@@ -123,14 +81,14 @@ function employeeExportColumns(
|
|||||||
{ header: "Wohnsitzland", get: (e) => e.address_country },
|
{ header: "Wohnsitzland", get: (e) => e.address_country },
|
||||||
{ header: "E-Mail", get: (e) => e.email },
|
{ header: "E-Mail", get: (e) => e.email },
|
||||||
{ header: "Telefon", get: (e) => e.phone },
|
{ header: "Telefon", get: (e) => e.phone },
|
||||||
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
|
{ header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" },
|
||||||
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
|
{ header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") },
|
||||||
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },
|
{ header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") },
|
||||||
{ header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
|
{ header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
|
||||||
{ header: "Position", get: (e) => e.job_title },
|
{ header: "Position", get: (e) => e.job_title },
|
||||||
{ header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") },
|
{ header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") },
|
||||||
{ header: "Planstelle", get: (e) => e.position_number },
|
{ header: "Führungskraft", get: (e) => e.is_lead },
|
||||||
{ header: "Leitungsplanstelle", get: (e) => e.is_chief },
|
{ header: "Org-Level", get: (e) => e.org_level },
|
||||||
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
|
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
|
||||||
{ header: "Wochenstunden", get: (e) => e.weekly_hours },
|
{ header: "Wochenstunden", get: (e) => e.weekly_hours },
|
||||||
// work_days is stored in click order (see RoleEmploymentFields), not
|
// work_days is stored in click order (see RoleEmploymentFields), not
|
||||||
|
|||||||
@@ -2,25 +2,25 @@ import { NextResponse, type NextRequest } from "next/server";
|
|||||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||||
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
|
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
|
||||||
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
|
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
|
||||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
import { requireHrUser } from "@/lib/supabase/auth";
|
||||||
import { withUser } from "@/lib/db";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
// Full raw event-log dump — one row per employee_history entry in the
|
// Full raw event-log dump — one row per employee_history entry in the
|
||||||
// selected period (default: current year), every event type unless one is
|
// selected period (default: current year), every event type unless one is
|
||||||
// picked, org columns resolved from each affected employee's current
|
// picked, org columns resolved from each affected employee's current
|
||||||
// placement (see loadEventHistory).
|
// placement (see loadEventHistory).
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const gate = await requireHrUser();
|
const supabase = await createClient();
|
||||||
if ("denied" in gate) return gate.denied;
|
const denied = await requireHrUser(supabase);
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const params = request.nextUrl.searchParams;
|
const params = request.nextUrl.searchParams;
|
||||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||||
const eventType = parseEventType(params.get("eventType"));
|
const eventType = parseEventType(params.get("eventType"));
|
||||||
|
|
||||||
const { lookups, events } = await withUser(gate.userId, async (tx) => {
|
|
||||||
const [{ lookups }, events] = await Promise.all([
|
const [{ lookups }, events] = await Promise.all([
|
||||||
loadOrgLookups(tx),
|
loadOrgLookups(supabase),
|
||||||
loadEventHistory(tx, {
|
loadEventHistory(supabase, {
|
||||||
eventType: eventType ?? undefined,
|
eventType: eventType ?? undefined,
|
||||||
division: params.get("division") ?? undefined,
|
division: params.get("division") ?? undefined,
|
||||||
location: params.get("location") ?? undefined,
|
location: params.get("location") ?? undefined,
|
||||||
@@ -28,8 +28,6 @@ export async function GET(request: NextRequest) {
|
|||||||
to: parseEventDateParam(params.get("to")),
|
to: parseEventDateParam(params.get("to")),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
return { lookups, events };
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns = eventExportColumns(lookups);
|
const columns = eventExportColumns(lookups);
|
||||||
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);
|
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);
|
||||||
@@ -46,9 +44,9 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn<ReportEvent>[] {
|
|||||||
{ header: "Vorname", get: (e) => e.first_name },
|
{ header: "Vorname", get: (e) => e.first_name },
|
||||||
{ header: "Nachname", get: (e) => e.last_name },
|
{ header: "Nachname", get: (e) => e.last_name },
|
||||||
{ header: "Position", get: (e) => e.job_title },
|
{ header: "Position", get: (e) => e.job_title },
|
||||||
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
|
{ header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" },
|
||||||
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
|
{ header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") },
|
||||||
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },
|
{ header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") },
|
||||||
{ header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
|
{ header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
|
||||||
{ header: "Beschreibung", get: (e) => e.description },
|
{ header: "Beschreibung", get: (e) => e.description },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -24,24 +24,23 @@ import {
|
|||||||
type ReportRow,
|
type ReportRow,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
import { requireHrUser } from "@/lib/supabase/auth";
|
||||||
import { withUser } from "@/lib/db";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
// Exports exactly the pivot table currently on screen (same mode/measure or
|
// Exports exactly the pivot table currently on screen (same mode/measure or
|
||||||
// event-type/group/split/filters, read from the query string the client
|
// event-type/group/split/filters, read from the query string the client
|
||||||
// already keeps in the URL) as a flat table — one row per group, one column
|
// already keeps in the URL) as a flat table — one row per group, one column
|
||||||
// per split value if a split is active.
|
// per split value if a split is active.
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const gate = await requireHrUser();
|
const supabase = await createClient();
|
||||||
if ("denied" in gate) return gate.denied;
|
const denied = await requireHrUser(supabase);
|
||||||
|
if (denied) return denied;
|
||||||
|
|
||||||
const params = request.nextUrl.searchParams;
|
const params = request.nextUrl.searchParams;
|
||||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||||
const mode = parseMode(params.get("mode"));
|
const mode = parseMode(params.get("mode"));
|
||||||
// Eine Transaktion für Nachschlagewerte und Daten: dort gilt der
|
const { lookups } = await loadOrgLookups(supabase);
|
||||||
// Sitzungskontext, und beide sehen denselben Lesestand.
|
|
||||||
const { rows, columns, filenameBase } = await withUser(gate.userId, async (tx) => {
|
|
||||||
const { lookups } = await loadOrgLookups(tx);
|
|
||||||
let rows: ReportRow[];
|
let rows: ReportRow[];
|
||||||
let columns: ExportColumn<ReportRow>[];
|
let columns: ExportColumn<ReportRow>[];
|
||||||
let filenameBase: string;
|
let filenameBase: string;
|
||||||
@@ -50,7 +49,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const group = parseEventGroupDimension(params.get("group"));
|
const group = parseEventGroupDimension(params.get("group"));
|
||||||
const split = parseEventSplitDimension(params.get("split"));
|
const split = parseEventSplitDimension(params.get("split"));
|
||||||
const eventType = parseEventType(params.get("eventType"));
|
const eventType = parseEventType(params.get("eventType"));
|
||||||
const events = await loadEventHistory(tx, {
|
const events = await loadEventHistory(supabase, {
|
||||||
eventType: eventType ?? undefined,
|
eventType: eventType ?? undefined,
|
||||||
division: params.get("division") ?? undefined,
|
division: params.get("division") ?? undefined,
|
||||||
location: params.get("location") ?? undefined,
|
location: params.get("location") ?? undefined,
|
||||||
@@ -65,7 +64,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const group = parseGroupDimension(params.get("group"));
|
const group = parseGroupDimension(params.get("group"));
|
||||||
const split = parseSplitDimension(params.get("split"));
|
const split = parseSplitDimension(params.get("split"));
|
||||||
const asOf = parseIsoDateParam(params.get("asOf"));
|
const asOf = parseIsoDateParam(params.get("asOf"));
|
||||||
const employees = await loadSnapshotEmployees(tx, {
|
const employees = await loadSnapshotEmployees(supabase, {
|
||||||
division: params.get("division") ?? undefined,
|
division: params.get("division") ?? undefined,
|
||||||
location: params.get("location") ?? undefined,
|
location: params.get("location") ?? undefined,
|
||||||
status: params.get("status") ?? undefined,
|
status: params.get("status") ?? undefined,
|
||||||
@@ -77,11 +76,7 @@ export async function GET(request: NextRequest) {
|
|||||||
filenameBase = `bericht-${measure}-${group}`;
|
filenameBase = `bericht-${measure}-${group}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { rows, columns, filenameBase };
|
|
||||||
});
|
|
||||||
|
|
||||||
const filename = exportFilename(filenameBase, format);
|
const filename = exportFilename(filenameBase, format);
|
||||||
|
|
||||||
const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns);
|
const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns);
|
||||||
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
|
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
|
||||||
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.
|
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
|
||||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
|
||||||
import { withUser } from "@/lib/db";
|
|
||||||
import { bestandLaden, laden, type Ladebericht } from "@/lib/import/load";
|
|
||||||
import { dateiLesen, type ImportSheet } from "@/lib/import/parse";
|
|
||||||
import { pruefe, type Befund } from "@/lib/import/validate";
|
|
||||||
|
|
||||||
// Massenimport — Prüflauf und Übernahme über denselben Weg.
|
|
||||||
//
|
|
||||||
// Es gibt bewusst **keinen** Zwischenspeicher zwischen beiden Schritten. Die
|
|
||||||
// Oberfläche schickt die Datei zweimal: einmal mit `pruefen=1`, um den
|
|
||||||
// Bericht zu zeigen, und nach der Bestätigung noch einmal zum Übernehmen.
|
|
||||||
// Das kostet eine Übertragung und erspart serverseitigen Zustand, der
|
|
||||||
// ablaufen, vollaufen oder zwischen zwei Personen verwechselt werden kann.
|
|
||||||
//
|
|
||||||
// Beide Läufe sehen denselben Bestand, weil Prüfung und Schreiben in
|
|
||||||
// derselben Transaktion stattfinden. Zwischen „geprüft" und „geschrieben"
|
|
||||||
// passt sonst eine fremde Änderung — etwa jemand, der dieselbe Planstelle
|
|
||||||
// besetzt.
|
|
||||||
|
|
||||||
/** Bricht die Transaktion ab, ohne einen Fehler zu sein. */
|
|
||||||
class Rueckabwicklung extends Error {
|
|
||||||
constructor(readonly nutzlast: unknown) {
|
|
||||||
super("Prüflauf");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const maxDuration = 120;
|
|
||||||
|
|
||||||
type Antwort = {
|
|
||||||
ok: boolean;
|
|
||||||
geprueft: boolean;
|
|
||||||
blaetter: string[];
|
|
||||||
fehler: Befund[];
|
|
||||||
hinweise: Befund[];
|
|
||||||
anzahl: Record<string, number>;
|
|
||||||
bericht?: Ladebericht;
|
|
||||||
meldung?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
const gate = await requireHrUser();
|
|
||||||
if ("denied" in gate) return gate.denied;
|
|
||||||
|
|
||||||
const form = await request.formData();
|
|
||||||
const nurPruefen = form.get("pruefen") === "1";
|
|
||||||
const dateien = form.getAll("datei").filter((f): f is File => f instanceof File);
|
|
||||||
|
|
||||||
if (dateien.length === 0) {
|
|
||||||
return NextResponse.json({ ok: false, meldung: "Keine Datei erhalten." }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mehrere Dateien werden zusammengesetzt: eine Mappe mit allen Blättern
|
|
||||||
// oder eine CSV je Blatt sind derselbe Vorgang.
|
|
||||||
const blaetter: ImportSheet[] = [];
|
|
||||||
const lesefehler: string[] = [];
|
|
||||||
for (const datei of dateien) {
|
|
||||||
const ergebnis = await dateiLesen(datei.name, await datei.arrayBuffer());
|
|
||||||
blaetter.push(...ergebnis.blaetter);
|
|
||||||
lesefehler.push(...ergebnis.fehler);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lesefehler.length > 0) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
geprueft: true,
|
|
||||||
blaetter: blaetter.map((b) => b.name),
|
|
||||||
fehler: lesefehler.map((m) => ({ blatt: "Datei", zeile: null, spalte: null, meldung: m })),
|
|
||||||
hinweise: [],
|
|
||||||
anzahl: {},
|
|
||||||
} satisfies Antwort,
|
|
||||||
{ status: 422 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const profil = await withUser(gate.userId, (tx) =>
|
|
||||||
tx.selectFrom("profiles").select(["full_name", "email"]).where("id", "=", gate.userId).executeTakeFirst()
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const antwort = await withUser(gate.userId, async (tx) => {
|
|
||||||
const bestand = await bestandLaden(tx);
|
|
||||||
const geprueft = pruefe(blaetter, bestand);
|
|
||||||
|
|
||||||
const basis: Antwort = {
|
|
||||||
ok: geprueft.fehler.length === 0,
|
|
||||||
geprueft: true,
|
|
||||||
blaetter: blaetter.map((b) => b.name),
|
|
||||||
fehler: geprueft.fehler,
|
|
||||||
hinweise: geprueft.hinweise,
|
|
||||||
anzahl: geprueft.anzahl,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fehler oder Prüflauf: die Transaktion wird zurückgerollt. Beim
|
|
||||||
// Prüflauf hat sie trotzdem echte Abfragen gemacht — der Bericht
|
|
||||||
// beruht also auf dem tatsächlichen Bestand, nicht auf einer Kopie.
|
|
||||||
if (!basis.ok || nurPruefen) throw new Rueckabwicklung({ ...basis, geprueft: nurPruefen || !basis.ok });
|
|
||||||
|
|
||||||
const bericht = await laden(tx, geprueft.datensatz, bestand, {
|
|
||||||
userId: gate.userId,
|
|
||||||
name: profil?.full_name || profil?.email || "Unbekannt",
|
|
||||||
});
|
|
||||||
return { ...basis, geprueft: false, bericht };
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(antwort);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof Rueckabwicklung) {
|
|
||||||
const nutzlast = err.nutzlast as Antwort;
|
|
||||||
return NextResponse.json(nutzlast, { status: nutzlast.ok ? 200 : 422 });
|
|
||||||
}
|
|
||||||
// Ein echter Fehler beim Schreiben. Die Transaktion ist zurückgerollt,
|
|
||||||
// es steht also nichts Halbes in der Datenbank.
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
ok: false,
|
|
||||||
geprueft: false,
|
|
||||||
blaetter: blaetter.map((b) => b.name),
|
|
||||||
fehler: [],
|
|
||||||
hinweise: [],
|
|
||||||
anzahl: {},
|
|
||||||
meldung:
|
|
||||||
err instanceof Error
|
|
||||||
? `Der Import wurde vollständig zurückgenommen. Grund: ${err.message}`
|
|
||||||
: "Der Import wurde vollständig zurückgenommen.",
|
|
||||||
} satisfies Antwort,
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import ExcelJS from "exceljs";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
|
||||||
import { exportFilename, exportResponseHeaders } from "@/lib/export";
|
|
||||||
import { BLAETTER } from "@/lib/import/schema";
|
|
||||||
|
|
||||||
// Die Vorlage entsteht aus demselben Schema wie die Prüfung.
|
|
||||||
//
|
|
||||||
// Das ist der Punkt: eine von Hand gepflegte Beispieldatei läuft dem Code
|
|
||||||
// hinterher, und dann verlangt die Vorlage eine Spalte, die es nicht mehr
|
|
||||||
// gibt — oder umgekehrt. Hier kann das nicht passieren; kommt in schema.ts
|
|
||||||
// eine Spalte dazu, steht sie beim nächsten Herunterladen drin.
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
const gate = await requireHrUser();
|
|
||||||
if ("denied" in gate) return gate.denied;
|
|
||||||
|
|
||||||
const mappe = new ExcelJS.Workbook();
|
|
||||||
mappe.creator = "Alpenwerk HR";
|
|
||||||
mappe.created = new Date();
|
|
||||||
|
|
||||||
const hinweise = mappe.addWorksheet("Hinweise");
|
|
||||||
hinweise.columns = [
|
|
||||||
{ header: "Blatt", width: 16 },
|
|
||||||
{ header: "Spalte", width: 26 },
|
|
||||||
{ header: "Pflicht", width: 9 },
|
|
||||||
{ header: "Format", width: 30 },
|
|
||||||
{ header: "Hinweis", width: 70 },
|
|
||||||
];
|
|
||||||
hinweise.getRow(1).font = { bold: true };
|
|
||||||
|
|
||||||
const formatText = (typ: (typeof BLAETTER)[number]["spalten"][number]["typ"]): string => {
|
|
||||||
switch (typ.art) {
|
|
||||||
case "datum":
|
|
||||||
return "Datum (31.12.2026)";
|
|
||||||
case "zahl":
|
|
||||||
return "Zahl (38,5)";
|
|
||||||
case "ganzzahl":
|
|
||||||
return "Ganze Zahl";
|
|
||||||
case "janein":
|
|
||||||
return "ja / nein";
|
|
||||||
case "liste":
|
|
||||||
return typ.werte ? `Mehrere mit Semikolon aus: ${typ.werte.join(", ")}` : "Mehrere mit Semikolon";
|
|
||||||
case "auswahl":
|
|
||||||
return typ.werte.join(" | ");
|
|
||||||
default:
|
|
||||||
return "Text";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const schema of BLAETTER) {
|
|
||||||
hinweise.addRow([schema.name, "", "", "", schema.zweck]).font = { bold: true };
|
|
||||||
for (const s of schema.spalten) {
|
|
||||||
hinweise.addRow([schema.name, s.name, s.pflicht ? "ja" : "", formatText(s.typ), s.hinweis]);
|
|
||||||
}
|
|
||||||
hinweise.addRow([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const schema of BLAETTER) {
|
|
||||||
const blatt = mappe.addWorksheet(schema.name);
|
|
||||||
blatt.columns = schema.spalten.map((s) => ({
|
|
||||||
header: s.name,
|
|
||||||
width: Math.max(12, Math.min(28, s.name.length + 4)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const kopf = blatt.getRow(1);
|
|
||||||
kopf.font = { bold: true };
|
|
||||||
kopf.eachCell((zelle, i) => {
|
|
||||||
const spalte = schema.spalten[i - 1];
|
|
||||||
if (!spalte) return;
|
|
||||||
// Pflichtspalten sichtbar markieren — sonst ist die erste Rückmeldung
|
|
||||||
// eine Fehlerliste statt eines Hinweises beim Ausfüllen.
|
|
||||||
if (spalte.pflicht) {
|
|
||||||
zelle.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFFDE7EF" } };
|
|
||||||
}
|
|
||||||
const teile = [spalte.pflicht ? "Pflichtfeld." : "Optional.", formatText(spalte.typ), spalte.hinweis].filter(Boolean);
|
|
||||||
zelle.note = teile.join("\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Eine Beispielzeile. Alles als Text, damit Excel nicht selbst
|
|
||||||
// interpretiert — der Leser deutet die Werte ohnehin.
|
|
||||||
const beispiel = schema.spalten.map((s) => s.beispiel);
|
|
||||||
if (beispiel.some(Boolean)) {
|
|
||||||
const zeile = blatt.addRow(beispiel);
|
|
||||||
zeile.font = { italic: true, color: { argb: "FF8A8A8A" } };
|
|
||||||
zeile.eachCell((z) => {
|
|
||||||
z.numFmt = "@";
|
|
||||||
});
|
|
||||||
}
|
|
||||||
blatt.views = [{ state: "frozen", ySplit: 1 }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const puffer = await mappe.xlsx.writeBuffer();
|
|
||||||
const dateiname = exportFilename("import-vorlage", "xlsx");
|
|
||||||
return new NextResponse(new Blob([puffer as BlobPart]), { headers: exportResponseHeaders(dateiname, "xlsx") });
|
|
||||||
}
|
|
||||||
65
auth.ts
65
auth.ts
@@ -1,65 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import NextAuth from "next-auth";
|
|
||||||
import { authConfig } from "@/lib/auth/config";
|
|
||||||
import { asSystem, sql } from "@/lib/db";
|
|
||||||
|
|
||||||
// Die vollständige Anmeldung — die Fassung, die die Datenbank kennt.
|
|
||||||
//
|
|
||||||
// Aufgeteilt ist sie, weil proxy.ts nur den Teil aus lib/auth/config.ts lädt.
|
|
||||||
// Hier kommt das dazu, was einmal pro Anmeldung passieren muss: aus der
|
|
||||||
// Kennung, die Entra ausstellt, eine Kennung machen, die diese Anwendung
|
|
||||||
// versteht.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Legt die app_users-Zeile an oder frischt sie auf und liefert die Kennung,
|
|
||||||
* die überall sonst als `userId` durchgereicht wird.
|
|
||||||
*
|
|
||||||
* Die Datenbankfunktion ist SECURITY DEFINER und darf genau dieses eine:
|
|
||||||
* app_users schreiben. Für den einen Schreibvorgang, für den es noch keinen
|
|
||||||
* Sitzungskontext geben kann, ist das der kleinstmögliche Hebel — früher lag
|
|
||||||
* hier ein Dienstschlüssel, der jede Zeile jeder Tabelle lesen konnte.
|
|
||||||
*/
|
|
||||||
async function upsertAppUser(externalId: string, email: string, fullName: string | null): Promise<string> {
|
|
||||||
const row = await asSystem(async (tx) => {
|
|
||||||
const result = await sql<{ id: string }>`
|
|
||||||
select app_upsert_user(${externalId}, ${email}, ${fullName}) as id
|
|
||||||
`.execute(tx);
|
|
||||||
return result.rows[0];
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!row?.id) throw new Error("app_upsert_user() lieferte keine Kennung.");
|
|
||||||
return row.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const { handlers, auth, signIn, signOut } = NextAuth(() => {
|
|
||||||
const base = authConfig();
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
callbacks: {
|
|
||||||
// Die Rückrufe aus der Basis **behalten**, nicht ersetzen: dort liegt
|
|
||||||
// session(), das die Kennung aus dem Token auf die Sitzung legt. Ein
|
|
||||||
// schlichtes `callbacks: { jwt }` hätte es stillschweigend entfernt.
|
|
||||||
...base.callbacks,
|
|
||||||
async jwt({ token, profile }) {
|
|
||||||
// `profile` liegt nur beim ersten Durchlauf nach der Rückkehr von Entra
|
|
||||||
// vor. Danach wird das Token nur noch weitergereicht — die Datenbank
|
|
||||||
// wird also einmal pro Anmeldung befragt, nicht einmal pro Aufruf.
|
|
||||||
if (!profile) return token;
|
|
||||||
|
|
||||||
const externalId = typeof profile.oid === "string" ? profile.oid : null;
|
|
||||||
const email = [profile.email, profile.preferred_username, profile.upn].find(
|
|
||||||
(v): v is string => typeof v === "string" && v.length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
// Lieber abbrechen als eine Sitzung ohne Kennung ausstellen: die käme
|
|
||||||
// als `null` bei withUser() an, und die Policies gäben dann konsequent
|
|
||||||
// nichts zurück — was sich als „die Anwendung ist leer" zeigt statt als
|
|
||||||
// Anmeldefehler.
|
|
||||||
if (!externalId || !email) throw new Error("Entra lieferte weder oid noch E-Mail-Adresse.");
|
|
||||||
|
|
||||||
token.uid = await upsertAppUser(externalId, email, typeof profile.name === "string" ? profile.name : null);
|
|
||||||
return token;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { SlideOver } from "@/components/ui/SlideOver";
|
|
||||||
import { actionBadgeStyle } from "@/lib/colors";
|
|
||||||
import type { AuditChange } from "@/lib/supabase/types";
|
|
||||||
|
|
||||||
// Eine Protokollzeile zum Aufklappen.
|
|
||||||
//
|
|
||||||
// Die Liste zeigt, *dass* etwas geändert wurde; hier steht, *was*. Beides in
|
|
||||||
// der Tabelle unterzubringen ginge nicht — bei sieben geänderten Feldern
|
|
||||||
// wäre die Zeile höher als der Bildschirm.
|
|
||||||
|
|
||||||
export type AuditEintrag = {
|
|
||||||
id: string;
|
|
||||||
occurred_at: string;
|
|
||||||
actor_name: string;
|
|
||||||
action: string;
|
|
||||||
target_label: string;
|
|
||||||
target_employee_id: string | null;
|
|
||||||
details: string | null;
|
|
||||||
changes: AuditChange[] | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const zeitFormat = new Intl.DateTimeFormat("de-AT", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
timeZone: "Europe/Vienna",
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 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;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setOffen(true)}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
className="w-full rounded px-2 py-1 text-left text-ink-muted hover:bg-brand-50 hover:text-ink
|
|
||||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
||||||
>
|
|
||||||
<span>{eintrag.details ?? "–"}</span>
|
|
||||||
{anzahl > 0 && (
|
|
||||||
<span className="ml-2 whitespace-nowrap rounded-full bg-brand-50 px-2 py-0.5 text-[11px] font-semibold text-brand-700">
|
|
||||||
{anzahl} {anzahl === 1 ? "Feld" : "Felder"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<SlideOver
|
|
||||||
open={offen}
|
|
||||||
onClose={() => setOffen(false)}
|
|
||||||
title={eintrag.target_label}
|
|
||||||
subtitle={`${eintrag.action} · ${zeitFormat.format(new Date(eintrag.occurred_at))}`}
|
|
||||||
>
|
|
||||||
<dl className="grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm">
|
|
||||||
<dt className="font-semibold text-ink-muted">Aktion</dt>
|
|
||||||
<dd>
|
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(eintrag.action)}`}>
|
|
||||||
{eintrag.action}
|
|
||||||
</span>
|
|
||||||
</dd>
|
|
||||||
<dt className="font-semibold text-ink-muted">Benutzer:in</dt>
|
|
||||||
<dd className="text-ink">{eintrag.actor_name}</dd>
|
|
||||||
<dt className="font-semibold text-ink-muted">Zeitpunkt</dt>
|
|
||||||
<dd className="tabular-nums text-ink">{zeitFormat.format(new Date(eintrag.occurred_at))}</dd>
|
|
||||||
{eintrag.target_employee_id && (
|
|
||||||
<>
|
|
||||||
<dt className="font-semibold text-ink-muted">Objekt</dt>
|
|
||||||
<dd>
|
|
||||||
<Link
|
|
||||||
href={`/employees/${eintrag.target_employee_id}`}
|
|
||||||
className="rounded font-semibold text-brand-700 hover:underline
|
|
||||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
||||||
>
|
|
||||||
{eintrag.target_label}
|
|
||||||
</Link>
|
|
||||||
</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{eintrag.details && (
|
|
||||||
<p className="mt-5 rounded-md bg-surface px-3 py-2 text-sm text-ink-body">{eintrag.details}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h3 className="mt-6 text-sm font-bold text-ink">Geänderte Felder</h3>
|
|
||||||
{anzahl > 0 ? (
|
|
||||||
<div className="mt-2 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>
|
|
||||||
) : (
|
|
||||||
// Kein Aufzählungszeichen für „nichts da“: der Grund ist wichtig,
|
|
||||||
// damit niemand einen Fehler vermutet.
|
|
||||||
<p className="mt-2 max-w-prose text-sm text-ink-muted">
|
|
||||||
Für diesen Eintrag liegen keine Feldwerte vor. Vorgänge wie Eintritt, Austritt oder Import erfassen keine
|
|
||||||
Einzelfelder — und Einträge von vor der Erweiterung des Protokolls haben nur die Feldnamen behalten, nicht
|
|
||||||
die Werte. Nachliefern lässt sich das nicht.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</SlideOver>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useFormStatus } from "react-dom";
|
|
||||||
|
|
||||||
// Eigene Schaltfläche statt der Button-Komponente: Microsoft gibt für „Sign in
|
|
||||||
// with Microsoft" Fläche, Schrift und Logo vor, und eine magentafarbene
|
|
||||||
// Variante wäre nicht nur regelwidrig, sondern auch irreführend — sie sähe aus
|
|
||||||
// wie eine Aktion *in* dieser Anwendung, während sie in Wirklichkeit auf eine
|
|
||||||
// fremde Anmeldeseite springt.
|
|
||||||
export function EntraSignInButton() {
|
|
||||||
const { pending } = useFormStatus();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={pending}
|
|
||||||
aria-busy={pending || undefined}
|
|
||||||
className="inline-flex w-full items-center justify-center gap-3 rounded-md border border-[#8c8c8c] bg-white px-4 py-3
|
|
||||||
text-sm font-semibold text-[#5e5e5e] transition-colors hover:bg-[#f3f3f3]
|
|
||||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500
|
|
||||||
disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 21 21" className="h-[18px] w-[18px] shrink-0" aria-hidden="true">
|
|
||||||
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
|
|
||||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
|
|
||||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
|
|
||||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
|
|
||||||
</svg>
|
|
||||||
{pending ? "Weiterleitung zu Microsoft…" : "Mit Firmenkonto anmelden"}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import { Avatar } from "@/components/ui/Avatar";
|
|||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { StatusChip } from "@/components/ui/StatusChip";
|
import { StatusChip } from "@/components/ui/StatusChip";
|
||||||
import { fmtFullName, tenure } from "@/lib/format";
|
import { fmtFullName, tenure } from "@/lib/format";
|
||||||
import type { OpenPositionResolved } from "@/lib/positions";
|
|
||||||
import type { Database } from "@/lib/supabase/types";
|
import type { Database } from "@/lib/supabase/types";
|
||||||
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
|
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
|
||||||
import { KarenzPanel } from "./panels/KarenzPanel";
|
import { KarenzPanel } from "./panels/KarenzPanel";
|
||||||
@@ -22,37 +21,42 @@ import { StammdatenTab } from "./tabs/StammdatenTab";
|
|||||||
import { VertragTab } from "./tabs/VertragTab";
|
import { VertragTab } from "./tabs/VertragTab";
|
||||||
|
|
||||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||||
|
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||||
|
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||||
|
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||||
type NoteRow = Database["public"]["Tables"]["employee_notes"]["Row"];
|
type NoteRow = Database["public"]["Tables"]["employee_notes"]["Row"];
|
||||||
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
|
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
|
||||||
/** Die Planstelle, die die Person heute innehat. */
|
type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean };
|
||||||
type PlacementInfo = { positionNumber: string; jobTitle: string; isChief: boolean; current: boolean };
|
|
||||||
|
|
||||||
type EmployeeDetailProps = {
|
type EmployeeDetailProps = {
|
||||||
employee: EmployeeRow;
|
employee: EmployeeRow;
|
||||||
placement: PlacementInfo | null;
|
|
||||||
breadcrumb: string;
|
|
||||||
manager: MiniEmployee | null;
|
manager: MiniEmployee | null;
|
||||||
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
|
||||||
formalManager: MiniEmployee | null;
|
|
||||||
directReports: MiniEmployee[];
|
directReports: MiniEmployee[];
|
||||||
history: HistoryRow[];
|
history: HistoryRow[];
|
||||||
dependents: Dependent[];
|
dependents: Dependent[];
|
||||||
notes: NoteRow[];
|
notes: NoteRow[];
|
||||||
|
divisions: Division[];
|
||||||
|
departments: Department[];
|
||||||
|
teams: Team[];
|
||||||
locations: Location[];
|
locations: Location[];
|
||||||
openPositions: OpenPositionResolved[];
|
openPositions: OpenPosition[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
|
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
|
||||||
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
|
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
|
||||||
|
|
||||||
export function EmployeeDetail(props: EmployeeDetailProps) {
|
export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||||
const { employee, placement, breadcrumb, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
|
const { employee, manager, directReports, history, dependents, notes, divisions, departments, teams, locations } = props;
|
||||||
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
|
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
|
||||||
const [panel, setPanel] = useState<PanelType>(null);
|
const [panel, setPanel] = useState<PanelType>(null);
|
||||||
|
|
||||||
|
const division = divisions.find((d) => d.id === employee.division_id);
|
||||||
|
const team = employee.team_id ? teams.find((t) => t.id === employee.team_id) : undefined;
|
||||||
|
const department = team ? departments.find((d) => d.id === team.department_id) : undefined;
|
||||||
|
const breadcrumb = [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–";
|
||||||
const location = locations.find((l) => l.id === employee.location_id);
|
const location = locations.find((l) => l.id === employee.location_id);
|
||||||
|
|
||||||
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
|
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
|
||||||
@@ -75,17 +79,8 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
</h2>
|
</h2>
|
||||||
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} />
|
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-ink-body">{placement?.jobTitle ?? employee.job_title}</p>
|
<p className="text-sm text-ink-body">{employee.job_title}</p>
|
||||||
<p className="text-xs text-ink-muted">
|
<p className="text-xs text-ink-muted">{breadcrumb}</p>
|
||||||
{breadcrumb}
|
|
||||||
{placement && (
|
|
||||||
<>
|
|
||||||
{" · Planstelle "}
|
|
||||||
{placement.positionNumber}
|
|
||||||
{placement.isChief && " (Leitung)"}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-ink-muted">
|
<p className="mt-1 text-xs text-ink-muted">
|
||||||
Pers.-Nr. {employee.personnel_number}
|
Pers.-Nr. {employee.personnel_number}
|
||||||
{employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
|
{employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
|
||||||
@@ -147,13 +142,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} dependents={dependents} />}
|
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} dependents={dependents} />}
|
||||||
{tab === "Vertrag" && <VertragTab employee={employee} />}
|
{tab === "Vertrag" && <VertragTab employee={employee} />}
|
||||||
{tab === "Organisation" && (
|
{tab === "Organisation" && (
|
||||||
<OrganisationTab
|
<OrganisationTab employeeId={employee.id} manager={manager} directReports={directReports} breadcrumb={breadcrumb} />
|
||||||
employeeId={employee.id}
|
|
||||||
manager={manager}
|
|
||||||
formalManager={formalManager}
|
|
||||||
directReports={directReports}
|
|
||||||
breadcrumb={breadcrumb}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{tab === "Historie" && <HistorieTab history={history} />}
|
{tab === "Historie" && <HistorieTab history={history} />}
|
||||||
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||||||
@@ -163,7 +152,10 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
|||||||
open={panel === "transfer"}
|
open={panel === "transfer"}
|
||||||
onClose={() => setPanel(null)}
|
onClose={() => setPanel(null)}
|
||||||
employee={employee}
|
employee={employee}
|
||||||
openPositions={openPositions}
|
divisions={divisions}
|
||||||
|
departments={departments}
|
||||||
|
teams={teams}
|
||||||
|
currentTeamId={employee.team_id}
|
||||||
/>
|
/>
|
||||||
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
||||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
|
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
|
|||||||
import { SearchInput } from "@/components/ui/SearchInput";
|
import { SearchInput } from "@/components/ui/SearchInput";
|
||||||
|
|
||||||
type EmployeeFiltersProps = {
|
type EmployeeFiltersProps = {
|
||||||
/** Der ganze Baum, in Tiefensuche-Reihenfolge. */
|
divisions: { id: string; name: string }[];
|
||||||
units: { id: string; name: string; unit_type: string }[];
|
|
||||||
depthOf: Map<string, number>;
|
|
||||||
locations: { id: string; name: string }[];
|
locations: { id: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -24,7 +22,7 @@ const STATUS_OPTIONS = [
|
|||||||
{ value: "Ausgetreten", label: "Ausgetreten" },
|
{ value: "Ausgetreten", label: "Ausgetreten" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersProps) {
|
export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -57,24 +55,16 @@ export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersPr
|
|||||||
{/* aria-label rather than a visible label: the filter bar is a single
|
{/* aria-label rather than a visible label: the filter bar is a single
|
||||||
horizontal row, and each select's first option already names it on
|
horizontal row, and each select's first option already names it on
|
||||||
screen. */}
|
screen. */}
|
||||||
{/* Der ganze Baum, nicht nur die oberste Ebene: die Auswahl greift
|
|
||||||
jeweils auf die Einheit *und alles darunter*, weshalb sich damit
|
|
||||||
auch nach einer einzelnen Abteilung oder einem Team filtern lässt.
|
|
||||||
Eingerückt statt gruppiert, weil optgroup keine Verschachtelung
|
|
||||||
kennt und die Tiefe hier beliebig ist. */}
|
|
||||||
<select
|
<select
|
||||||
aria-label="Nach Organisationseinheit filtern"
|
aria-label="Nach Bereich filtern"
|
||||||
defaultValue={searchParams.get("division") ?? ""}
|
defaultValue={searchParams.get("division") ?? ""}
|
||||||
onChange={(e) => updateParam("division", e.target.value)}
|
onChange={(e) => updateParam("division", e.target.value)}
|
||||||
className={FILTER_SELECT_CLASS}
|
className={FILTER_SELECT_CLASS}
|
||||||
>
|
>
|
||||||
<option value="">Alle Einheiten</option>
|
<option value="">Alle Bereiche</option>
|
||||||
{units
|
{divisions.map((d) => (
|
||||||
.filter((u) => u.unit_type !== "Gesellschaft")
|
<option key={d.id} value={d.id}>
|
||||||
.map((u) => (
|
{d.name}
|
||||||
<option key={u.id} value={u.id}>
|
|
||||||
{" ".repeat(Math.max(0, (depthOf.get(u.id) ?? 1) - 1) * 3)}
|
|
||||||
{u.name}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -7,51 +7,48 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { SelectField, TextField } from "@/components/ui/Field";
|
import { SelectField, TextField } from "@/components/ui/Field";
|
||||||
import { SlideOver } from "@/components/ui/SlideOver";
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import type { OpenPositionResolved } from "@/lib/positions";
|
|
||||||
import type { Database } from "@/lib/supabase/types";
|
import type { Database } from "@/lib/supabase/types";
|
||||||
|
|
||||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||||
|
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||||
|
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||||
|
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||||
|
|
||||||
type TransferPanelProps = {
|
type TransferPanelProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
employee: EmployeeRow;
|
employee: EmployeeRow;
|
||||||
openPositions: OpenPositionResolved[];
|
divisions: Division[];
|
||||||
|
departments: Department[];
|
||||||
|
teams: Team[];
|
||||||
|
currentTeamId: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Eine Versetzung ist der Wechsel auf eine andere Planstelle — nicht mehr die
|
export function TransferPanel({ open, onClose, employee, divisions, departments, teams, currentTeamId }: TransferPanelProps) {
|
||||||
// Angabe eines Zielteams samt frei getipptem Titel. Bereich, Abteilung und
|
|
||||||
// Team ergeben sich aus der Einheit der Zielplanstelle, die neue Tätigkeit aus
|
|
||||||
// ihrem Job. Damit kann eine Versetzung gar nicht erst irgendwo landen, wo es
|
|
||||||
// keine Stelle gibt.
|
|
||||||
export function TransferPanel({ open, onClose, employee, openPositions }: TransferPanelProps) {
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [effectiveDate, setEffectiveDate] = useState("");
|
const [effectiveDate, setEffectiveDate] = useState("");
|
||||||
const [positionId, setPositionId] = useState("");
|
const [divisionId, setDivisionId] = useState(employee.division_id);
|
||||||
|
const [teamId, setTeamId] = useState(currentTeamId ?? "");
|
||||||
|
const [newTitle, setNewTitle] = useState("");
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
const options = useMemo(
|
const teamsInDivision = useMemo(() => {
|
||||||
() =>
|
const deptIds = new Set(departments.filter((d) => d.division_id === divisionId).map((d) => d.id));
|
||||||
openPositions
|
return teams.filter((t) => deptIds.has(t.department_id));
|
||||||
.slice()
|
}, [departments, teams, divisionId]);
|
||||||
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
|
|
||||||
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
|
|
||||||
[openPositions]
|
|
||||||
);
|
|
||||||
|
|
||||||
const selected = openPositions.find((p) => p.id === positionId);
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!effectiveDate || !positionId) {
|
if (!effectiveDate || !teamId) {
|
||||||
showToast("Bitte Datum und Zielplanstelle angeben.", "error");
|
showToast("Bitte Datum und Zielteam angeben.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPending(true);
|
setPending(true);
|
||||||
const result = await transferEmployee({
|
const result = await transferEmployee({
|
||||||
employee_id: employee.id,
|
employee_id: employee.id,
|
||||||
effective_date: effectiveDate,
|
effective_date: effectiveDate,
|
||||||
target_position_id: positionId,
|
new_team_id: teamId,
|
||||||
|
new_title: newTitle || undefined,
|
||||||
});
|
});
|
||||||
setPending(false);
|
setPending(false);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -74,7 +71,7 @@ export function TransferPanel({ open, onClose, employee, openPositions }: Transf
|
|||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="ghost" onClick={onClose}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} pending={pending} disabled={options.length === 0}>
|
<Button onClick={handleSubmit} pending={pending}>
|
||||||
Versetzen
|
Versetzen
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -82,33 +79,31 @@ export function TransferPanel({ open, onClose, employee, openPositions }: Transf
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||||
{options.length === 0 ? (
|
|
||||||
<p className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
|
||||||
Es gibt derzeit keine unbesetzte Planstelle. Eine Versetzung setzt eine freie Zielplanstelle voraus — legen Sie
|
|
||||||
zuerst unter „Positionen“ eine an.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<SelectField
|
<SelectField
|
||||||
label="Zielplanstelle"
|
label="Neuer Bereich"
|
||||||
required
|
required
|
||||||
value={positionId}
|
value={divisionId}
|
||||||
onChange={setPositionId}
|
onChange={(v) => {
|
||||||
placeholder="Bitte wählen…"
|
setDivisionId(v);
|
||||||
options={options}
|
setTeamId("");
|
||||||
|
}}
|
||||||
|
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Neues Team"
|
||||||
|
required
|
||||||
|
value={teamId}
|
||||||
|
onChange={setTeamId}
|
||||||
|
placeholder="Bitte wählen…"
|
||||||
|
options={teamsInDivision.map((t) => ({ value: t.id, label: t.name }))}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Neuer Titel (optional)"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={setNewTitle}
|
||||||
|
placeholder={employee.job_title}
|
||||||
|
hint="Die neue Führungskraft wird automatisch anhand des Zielteams bestimmt."
|
||||||
/>
|
/>
|
||||||
{selected && (
|
|
||||||
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
|
||||||
<div className="font-semibold text-ink">{selected.title}</div>
|
|
||||||
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
|
|
||||||
<div className="mt-1 text-xs text-ink-muted">
|
|
||||||
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
|
|
||||||
{selected.managerName ? ` · berichtet an ${selected.managerName}` : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</SlideOver>
|
</SlideOver>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,13 +8,11 @@ type MiniEmployee = { id: string; first_name: string; last_name: string; job_tit
|
|||||||
type OrganisationTabProps = {
|
type OrganisationTabProps = {
|
||||||
employeeId: string;
|
employeeId: string;
|
||||||
manager: MiniEmployee | null;
|
manager: MiniEmployee | null;
|
||||||
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
|
||||||
formalManager: MiniEmployee | null;
|
|
||||||
directReports: MiniEmployee[];
|
directReports: MiniEmployee[];
|
||||||
breadcrumb: string;
|
breadcrumb: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OrganisationTab({ employeeId, manager, formalManager, directReports, breadcrumb }: OrganisationTabProps) {
|
export function OrganisationTab({ employeeId, manager, directReports, breadcrumb }: OrganisationTabProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
@@ -32,15 +30,7 @@ export function OrganisationTab({ employeeId, manager, formalManager, directRepo
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Führungskraft</h3>
|
||||||
{formalManager ? "Führungskraft (Vertretung)" : "Führungskraft"}
|
|
||||||
</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
|
|
||||||
besetzte Ebene.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{manager ? (
|
{manager ? (
|
||||||
<Link href={`/employees/${manager.id}`} className="flex w-fit items-center gap-3 rounded border border-border p-3 hover:bg-surface">
|
<Link href={`/employees/${manager.id}`} className="flex w-fit items-center gap-3 rounded border border-border p-3 hover:bg-surface">
|
||||||
<Avatar firstName={manager.first_name} lastName={manager.last_name} />
|
<Avatar firstName={manager.first_name} lastName={manager.last_name} />
|
||||||
|
|||||||
@@ -58,11 +58,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
|||||||
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
||||||
|
|
||||||
const stepValid = [
|
const stepValid = [
|
||||||
// E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne
|
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
|
||||||
// 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()) &&
|
|
||||||
svNummerOk,
|
|
||||||
Boolean(draft.positionId && draft.besetzung),
|
Boolean(draft.positionId && draft.besetzung),
|
||||||
Boolean(draft.entryDate && draft.workDays.length > 0),
|
Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||||
true,
|
true,
|
||||||
@@ -90,7 +86,6 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
|||||||
gender: draft.gender,
|
gender: draft.gender,
|
||||||
birth_date: draft.birthDate,
|
birth_date: draft.birthDate,
|
||||||
sv_nummer: draft.svNummer || undefined,
|
sv_nummer: draft.svNummer || undefined,
|
||||||
email: draft.email.trim(),
|
|
||||||
phone: draft.phone || undefined,
|
phone: draft.phone || undefined,
|
||||||
position_id: draft.positionId,
|
position_id: draft.positionId,
|
||||||
location_id: draft.locationId,
|
location_id: draft.locationId,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
|||||||
birthDate={draft.birthDate || null}
|
birthDate={draft.birthDate || null}
|
||||||
/>
|
/>
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<TextField label="E-Mail" required type="email" value={draft.email} onChange={(email) => update({ email })} />
|
<TextField label="E-Mail (privat)" type="email" value={draft.email} onChange={(email) => update({ email })} />
|
||||||
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
||||||
</div>
|
</div>
|
||||||
<SelectField
|
<SelectField
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/Button";
|
|
||||||
import { CARD_CLASS } from "@/components/ui/Card";
|
|
||||||
|
|
||||||
// Der Ablauf hat bewusst zwei Schritte: prüfen, dann übernehmen.
|
|
||||||
//
|
|
||||||
// Ein Import ist nicht rückgängig zu machen. Wer 800 Zeilen schickt, soll
|
|
||||||
// vorher sehen, was entstehen würde — und bei einem Fehler die Zeilennummer
|
|
||||||
// lesen, nicht „Import fehlgeschlagen".
|
|
||||||
|
|
||||||
type Befund = { blatt: string; zeile: number | null; spalte: string | null; wert?: string; meldung: string };
|
|
||||||
|
|
||||||
type Antwort = {
|
|
||||||
ok: boolean;
|
|
||||||
geprueft: boolean;
|
|
||||||
blaetter: string[];
|
|
||||||
fehler: Befund[];
|
|
||||||
hinweise: Befund[];
|
|
||||||
anzahl: Record<string, number>;
|
|
||||||
bericht?: Record<string, number>;
|
|
||||||
meldung?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Mehr als das zeigt niemand durch; der Rest steht in der Anzahl. */
|
|
||||||
const MAX_ANZEIGE = 200;
|
|
||||||
|
|
||||||
export function ImportWorkbench() {
|
|
||||||
const [dateien, setDateien] = useState<File[]>([]);
|
|
||||||
const [antwort, setAntwort] = useState<Antwort | null>(null);
|
|
||||||
const [laeuft, setLaeuft] = useState<"pruefen" | "uebernehmen" | null>(null);
|
|
||||||
const [fehlschlag, setFehlschlag] = useState<string | null>(null);
|
|
||||||
const eingabe = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
async function senden(nurPruefen: boolean) {
|
|
||||||
if (dateien.length === 0) return;
|
|
||||||
setLaeuft(nurPruefen ? "pruefen" : "uebernehmen");
|
|
||||||
setFehlschlag(null);
|
|
||||||
try {
|
|
||||||
const daten = new FormData();
|
|
||||||
for (const d of dateien) daten.append("datei", d);
|
|
||||||
if (nurPruefen) daten.append("pruefen", "1");
|
|
||||||
const antwort = await fetch("/api/import", { method: "POST", body: daten });
|
|
||||||
const inhalt = (await antwort.json()) as Antwort;
|
|
||||||
setAntwort(inhalt);
|
|
||||||
} catch {
|
|
||||||
setFehlschlag("Die Datei konnte nicht übertragen werden. Bitte erneut versuchen.");
|
|
||||||
} finally {
|
|
||||||
setLaeuft(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function neueAuswahl(liste: FileList | null) {
|
|
||||||
setDateien(liste ? Array.from(liste) : []);
|
|
||||||
// Ein alter Bericht zu einer neuen Datei ist schlimmer als keiner.
|
|
||||||
setAntwort(null);
|
|
||||||
setFehlschlag(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uebernommen = antwort?.bericht !== undefined;
|
|
||||||
const bereit = antwort?.ok === true && antwort.geprueft && !uebernommen;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<section className={`${CARD_CLASS} p-5`}>
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-bold text-ink">Datei wählen</h2>
|
|
||||||
<p className="mt-1 max-w-prose text-sm text-ink-muted">
|
|
||||||
Eine Excel-Mappe mit den Blättern <strong>Standorte</strong>, <strong>Organisation</strong>,{" "}
|
|
||||||
<strong>Jobkatalog</strong>, <strong>Planstellen</strong>, <strong>Personen</strong>,{" "}
|
|
||||||
<strong>Historie</strong> und <strong>Angehörige</strong> — oder je Blatt eine CSV-Datei, deren Name dem
|
|
||||||
Blatt entspricht. Nicht jedes Blatt muss dabei sein.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href="/api/import/template"
|
|
||||||
className="shrink-0 rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50
|
|
||||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
|
||||||
>
|
|
||||||
Vorlage herunterladen
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
|
||||||
<input
|
|
||||||
ref={eingabe}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept=".xlsx,.xlsm,.csv"
|
|
||||||
onChange={(e) => neueAuswahl(e.target.files)}
|
|
||||||
className="block w-full max-w-md text-sm text-ink-muted file:mr-3 file:rounded-md file:border file:border-border
|
|
||||||
file:bg-surface file:px-3 file:py-2 file:text-sm file:font-semibold file:text-ink hover:file:bg-brand-50"
|
|
||||||
/>
|
|
||||||
<Button onClick={() => senden(true)} disabled={dateien.length === 0 || laeuft !== null}>
|
|
||||||
{laeuft === "pruefen" ? "Wird geprüft…" : "Prüfen"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{dateien.length > 0 && (
|
|
||||||
<p className="mt-2 text-xs text-ink-muted">
|
|
||||||
{dateien.length === 1 ? dateien[0].name : `${dateien.length} Dateien`} ausgewählt
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{fehlschlag && (
|
|
||||||
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
|
|
||||||
{fehlschlag}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{antwort?.meldung && (
|
|
||||||
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
|
|
||||||
{antwort.meldung}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{antwort && !antwort.meldung && (
|
|
||||||
<section className={`${CARD_CLASS} p-5`}>
|
|
||||||
<h2 className="text-sm font-bold text-ink">{uebernommen ? "Übernommen" : "Ergebnis der Prüfung"}</h2>
|
|
||||||
|
|
||||||
<table className="mt-3 w-full max-w-md text-sm">
|
|
||||||
<tbody>
|
|
||||||
{Object.entries(uebernommen ? antwort.bericht! : antwort.anzahl)
|
|
||||||
.filter(([, n]) => n > 0)
|
|
||||||
.map(([blatt, n]) => (
|
|
||||||
<tr key={blatt} className="border-b border-border-subtle last:border-0">
|
|
||||||
<td className="py-1.5 text-ink-body">{blatt}</td>
|
|
||||||
<td className="py-1.5 text-right font-semibold tabular-nums text-ink">{n}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{uebernommen ? (
|
|
||||||
<p className="mt-4 text-sm text-ink-muted">
|
|
||||||
Die Daten stehen jetzt in der Anwendung. Der Vorgang ist im Audit-Log vermerkt.
|
|
||||||
</p>
|
|
||||||
) : antwort.ok ? (
|
|
||||||
<>
|
|
||||||
<p className="mt-4 text-sm text-ink-body">
|
|
||||||
Keine Beanstandung. Beim Übernehmen entsteht genau das oben Gezeigte — alles in einem Zug oder gar
|
|
||||||
nichts.
|
|
||||||
</p>
|
|
||||||
<div className="mt-3">
|
|
||||||
<Button onClick={() => senden(false)} disabled={laeuft !== null || !bereit}>
|
|
||||||
{laeuft === "uebernehmen" ? "Wird übernommen…" : "Übernehmen"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="mt-4 text-sm font-semibold text-danger-text">
|
|
||||||
{antwort.fehler.length} Beanstandung{antwort.fehler.length === 1 ? "" : "en"} — es wurde nichts
|
|
||||||
geschrieben.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{antwort && antwort.fehler.length > 0 && (
|
|
||||||
<BefundListe titel="Zu beheben" befunde={antwort.fehler} art="fehler" />
|
|
||||||
)}
|
|
||||||
{antwort && antwort.hinweise.length > 0 && (
|
|
||||||
<BefundListe titel="Hinweise" befunde={antwort.hinweise} art="hinweis" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BefundListe({ titel, befunde, art }: { titel: string; befunde: Befund[]; art: "fehler" | "hinweis" }) {
|
|
||||||
const sichtbar = befunde.slice(0, MAX_ANZEIGE);
|
|
||||||
return (
|
|
||||||
<section className={`overflow-x-auto ${CARD_CLASS}`}>
|
|
||||||
<h2 className="px-5 pt-5 text-sm font-bold text-ink">
|
|
||||||
{titel} <span className="font-normal text-ink-muted">({befunde.length})</span>
|
|
||||||
</h2>
|
|
||||||
<table className="mt-3 w-full min-w-[640px] text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-y border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
|
||||||
<th className="px-5 py-2">Blatt</th>
|
|
||||||
<th className="px-3 py-2">Zeile</th>
|
|
||||||
<th className="px-3 py-2">Spalte</th>
|
|
||||||
<th className="px-3 py-2">Wert</th>
|
|
||||||
<th className="px-5 py-2">Was zu tun ist</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sichtbar.map((b, i) => (
|
|
||||||
<tr key={i} className="border-b border-border-subtle last:border-0">
|
|
||||||
<td className="whitespace-nowrap px-5 py-2 text-ink-body">{b.blatt}</td>
|
|
||||||
<td className="px-3 py-2 tabular-nums text-ink-muted">{b.zeile ?? "–"}</td>
|
|
||||||
<td className="px-3 py-2 text-ink-body">{b.spalte ?? "–"}</td>
|
|
||||||
<td className="max-w-[16rem] truncate px-3 py-2 text-ink-muted" title={b.wert}>
|
|
||||||
{b.wert ?? ""}
|
|
||||||
</td>
|
|
||||||
<td className={`px-5 py-2 ${art === "fehler" ? "text-danger-text" : "text-ink-muted"}`}>{b.meldung}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{befunde.length > sichtbar.length && (
|
|
||||||
<p className="px-5 py-3 text-xs text-ink-muted">
|
|
||||||
Weitere {befunde.length - sichtbar.length} nicht angezeigt. Oft hängen viele Meldungen an einer Ursache —
|
|
||||||
nach der Korrektur erneut prüfen.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,17 +2,22 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||||
|
import type { OpenPositionResolved } from "@/lib/positions";
|
||||||
import { AsOfPicker } from "./AsOfPicker";
|
import { AsOfPicker } from "./AsOfPicker";
|
||||||
import { EmployeeTree } from "./EmployeeTree";
|
import { EmployeeTree } from "./EmployeeTree";
|
||||||
import { PositionTree } from "./PositionTree";
|
import { PositionTree } from "./PositionTree";
|
||||||
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
|
import { ReorgWorkbench } from "./ReorgWorkbench";
|
||||||
|
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
|
||||||
|
|
||||||
type View = "ma" | "pos";
|
type View = "ma" | "pos" | "reo";
|
||||||
|
|
||||||
type OrgChartClientProps = {
|
type OrgChartClientProps = {
|
||||||
employees: OrgEmployee[];
|
employees: OrgEmployee[];
|
||||||
units: OrgUnitNode[];
|
divisions: OrgDivision[];
|
||||||
vacancies: OrgVacancy[];
|
departments: OrgDepartment[];
|
||||||
|
teams: OrgTeam[];
|
||||||
|
openPositions: OpenPositionResolved[];
|
||||||
|
reorgScenarios: ReorgScenarioSummary[];
|
||||||
asOf: string;
|
asOf: string;
|
||||||
today: string;
|
today: string;
|
||||||
projectedCount: number;
|
projectedCount: number;
|
||||||
@@ -23,8 +28,11 @@ type OrgChartClientProps = {
|
|||||||
|
|
||||||
export function OrgChartClient({
|
export function OrgChartClient({
|
||||||
employees,
|
employees,
|
||||||
units,
|
divisions,
|
||||||
vacancies,
|
departments,
|
||||||
|
teams,
|
||||||
|
openPositions,
|
||||||
|
reorgScenarios,
|
||||||
asOf,
|
asOf,
|
||||||
today,
|
today,
|
||||||
projectedCount,
|
projectedCount,
|
||||||
@@ -32,6 +40,7 @@ export function OrgChartClient({
|
|||||||
focusId,
|
focusId,
|
||||||
}: OrgChartClientProps) {
|
}: OrgChartClientProps) {
|
||||||
const [view, setView] = useState<View>("ma");
|
const [view, setView] = useState<View>("ma");
|
||||||
|
const isToday = asOf === today;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -40,14 +49,35 @@ export function OrgChartClient({
|
|||||||
onChange={setView}
|
onChange={setView}
|
||||||
options={[
|
options={[
|
||||||
{ value: "ma", label: "Mitarbeiter" },
|
{ value: "ma", label: "Mitarbeiter" },
|
||||||
{ value: "pos", label: "Organisation" },
|
{ value: "pos", label: "Positionen" },
|
||||||
|
{ value: "reo", label: "Reorganisation" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{view !== "reo" && (
|
||||||
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
|
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
|
||||||
|
)}
|
||||||
|
|
||||||
{view === "ma" && <EmployeeTree employees={employees} focusId={focusId} />}
|
{view === "ma" && <EmployeeTree employees={employees} focusId={focusId} />}
|
||||||
{view === "pos" && <PositionTree employees={employees} units={units} vacancies={vacancies} />}
|
{view === "pos" && (
|
||||||
|
<PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} />
|
||||||
|
)}
|
||||||
|
{view === "reo" &&
|
||||||
|
(isToday ? (
|
||||||
|
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
|
||||||
|
) : (
|
||||||
|
// A reorg planned against a past or projected roster would be
|
||||||
|
// applied to the *live* org anyway — better to send the user back
|
||||||
|
// to today than to let them assemble moves from a roster that is
|
||||||
|
// not the one the change would hit.
|
||||||
|
<div className="rounded border border-border bg-white p-6 text-sm text-ink-body">
|
||||||
|
<p className="font-semibold text-ink">Reorganisation nur zum heutigen Stand</p>
|
||||||
|
<p className="mt-1 text-ink-muted">
|
||||||
|
Es ist ein abweichender Stichtag gewählt. Reorganisationen wirken immer auf die aktuelle Struktur — wechseln
|
||||||
|
Sie zurück auf „Heute“, um eine zu planen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,34 +2,56 @@
|
|||||||
|
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||||
|
import type { OpenPositionResolved } from "@/lib/positions";
|
||||||
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
||||||
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
|
import type { ChartNode, OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
|
||||||
|
|
||||||
// Die Struktursicht. Sie folgt jetzt org_units.parent_id statt einer fest
|
|
||||||
// verdrahteten Abfolge Bereich → Abteilung → Team: eine fünfte Ebene ist
|
|
||||||
// damit eine Datenfrage und keine Änderung an dieser Datei.
|
|
||||||
//
|
|
||||||
// Liste und Grafik werden aus *einem* Baum gerendert. Vorher gab es dieselbe
|
|
||||||
// Hierarchie zweimal — einmal als JSX-Schachtelung, einmal als ChartNode —
|
|
||||||
// und die beiden konnten auseinanderlaufen, ohne dass es auffiel.
|
|
||||||
|
|
||||||
type ViewMode = "list" | "graph";
|
type ViewMode = "list" | "graph";
|
||||||
|
|
||||||
|
function TreeRow({
|
||||||
|
depth,
|
||||||
|
expandable,
|
||||||
|
expandedNow,
|
||||||
|
onToggle,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
depth: number;
|
||||||
|
expandable: boolean;
|
||||||
|
expandedNow: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface" style={{ paddingLeft: depth * 24 + 8 }}>
|
||||||
|
{expandable ? (
|
||||||
|
<button type="button" onClick={onToggle} className="shrink-0 text-ink-muted">
|
||||||
|
{expandedNow ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type PositionTreeProps = {
|
type PositionTreeProps = {
|
||||||
employees: OrgEmployee[];
|
employees: OrgEmployee[];
|
||||||
units: OrgUnitNode[];
|
divisions: OrgDivision[];
|
||||||
vacancies: OrgVacancy[];
|
departments: OrgDepartment[];
|
||||||
|
teams: OrgTeam[];
|
||||||
|
openPositions: OpenPositionResolved[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PositionTree({ employees, units, vacancies }: PositionTreeProps) {
|
export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) {
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`)));
|
const [expanded, setExpanded] = useState<Set<string>>(new Set(["root"]));
|
||||||
const [mode, setMode] = useState<ViewMode>("list");
|
const [mode, setMode] = useState<ViewMode>("list");
|
||||||
|
|
||||||
// useCallback-stabil: das Layout-Memo von GraphOrgChart hängt an diesen
|
// useCallback-stable: GraphOrgChart's layout memo depends on these
|
||||||
// Referenzen, instabile Funktionen erzwängen sonst bei jedem Re-Render ein
|
// references, so unstable functions would force a Dagre re-layout on
|
||||||
// neues Dagre-Layout.
|
// every unrelated re-render.
|
||||||
const toggle = useCallback((id: string) => {
|
const toggle = useCallback((id: string) => {
|
||||||
setExpanded((prev) => {
|
setExpanded((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -38,178 +60,216 @@ export function PositionTree({ employees, units, vacancies }: PositionTreeProps)
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
|
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
|
||||||
|
|
||||||
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]);
|
const ceo = employees.find((e) => e.org_level === 0) ?? null;
|
||||||
|
const { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle } = useMemo(() => {
|
||||||
return (
|
const divisionHeadByDivision = new Map<string, OrgEmployee>();
|
||||||
<div className="flex flex-col gap-4">
|
const teamLeadByTeam = new Map<string, OrgEmployee>();
|
||||||
<div className="flex justify-end">
|
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
|
||||||
<SegmentedControl<ViewMode>
|
|
||||||
value={mode}
|
|
||||||
onChange={setMode}
|
|
||||||
options={[
|
|
||||||
{ value: "list", label: "Liste" },
|
|
||||||
{ value: "graph", label: "Grafisch" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{mode === "graph" ? (
|
|
||||||
<LazyGraphOrgChart tree={tree} isExpanded={isExpanded} onToggle={toggle} />
|
|
||||||
) : (
|
|
||||||
<div className="rounded border border-border bg-white p-4">
|
|
||||||
{tree.map((node) => (
|
|
||||||
<ListNode key={node.id} node={node} depth={0} expanded={expanded} onToggle={toggle} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ListNode({
|
|
||||||
node,
|
|
||||||
depth,
|
|
||||||
expanded,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
node: ChartNode;
|
|
||||||
depth: number;
|
|
||||||
expanded: Set<string>;
|
|
||||||
onToggle: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const expandable = node.children.length > 0;
|
|
||||||
const open = expanded.has(node.id);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface"
|
|
||||||
style={{ paddingLeft: depth * 24 + 8 }}
|
|
||||||
>
|
|
||||||
{expandable ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onToggle(node.id)}
|
|
||||||
className="shrink-0 text-ink-muted"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-label={open ? `${node.label} zuklappen` : `${node.label} aufklappen`}
|
|
||||||
>
|
|
||||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="w-4 shrink-0" />
|
|
||||||
)}
|
|
||||||
{node.href ? (
|
|
||||||
<Link
|
|
||||||
href={node.href}
|
|
||||||
className={node.kind === "vacancy" ? "text-sm text-brand-700 hover:underline" : "text-sm text-ink-body hover:text-brand-700 hover:underline"}
|
|
||||||
>
|
|
||||||
{node.label}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className={node.kind === "group" ? "text-sm text-ink" : "text-sm font-semibold text-ink"}>{node.label}</span>
|
|
||||||
)}
|
|
||||||
{node.sublabel && (
|
|
||||||
<span className={node.vacant ? "text-xs font-semibold text-warning-text" : "text-xs text-ink-muted"}>{node.sublabel}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{open && node.children.map((child) => <ListNode key={child.id} node={child} depth={depth + 1} expanded={expanded} onToggle={onToggle} />)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Je Einheit: die Leitung als Kopfzeile, darunter die untergeordneten
|
|
||||||
* Einheiten, dann die eigenen Mitarbeitenden nach Tätigkeit gruppiert und
|
|
||||||
* zuletzt die unbesetzten Planstellen.
|
|
||||||
*/
|
|
||||||
export function buildUnitTree(units: OrgUnitNode[], employees: OrgEmployee[], vacancies: OrgVacancy[]): ChartNode[] {
|
|
||||||
const childUnits = new Map<string | null, OrgUnitNode[]>();
|
|
||||||
for (const u of units) {
|
|
||||||
const list = childUnits.get(u.parent_id) ?? [];
|
|
||||||
list.push(u);
|
|
||||||
childUnits.set(u.parent_id, list);
|
|
||||||
}
|
|
||||||
for (const list of childUnits.values()) list.sort((a, b) => a.org_number.localeCompare(b.org_number));
|
|
||||||
|
|
||||||
const chiefOf = new Map<string, OrgEmployee>();
|
|
||||||
const staffOf = new Map<string, OrgEmployee[]>();
|
|
||||||
for (const e of employees) {
|
for (const e of employees) {
|
||||||
if (e.is_chief) chiefOf.set(e.org_unit_id, e);
|
if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
|
||||||
else {
|
if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
|
||||||
const list = staffOf.get(e.org_unit_id) ?? [];
|
if (!e.is_lead && e.org_level === 3 && e.team_id) {
|
||||||
list.push(e);
|
if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
|
||||||
staffOf.set(e.org_unit_id, list);
|
const byTitle = icsByTeamAndTitle.get(e.team_id)!;
|
||||||
|
if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []);
|
||||||
|
byTitle.get(e.job_title)!.push(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle };
|
||||||
const vacantOf = new Map<string, OrgVacancy[]>();
|
}, [employees]);
|
||||||
for (const v of vacancies) {
|
const openByTeam = useMemo(() => {
|
||||||
const list = vacantOf.get(v.org_unit_id) ?? [];
|
const map = new Map<string, OpenPositionResolved[]>();
|
||||||
list.push(v);
|
for (const p of openPositions) {
|
||||||
vacantOf.set(v.org_unit_id, list);
|
if (!map.has(p.team_id)) map.set(p.team_id, []);
|
||||||
|
map.get(p.team_id)!.push(p);
|
||||||
}
|
}
|
||||||
|
return map;
|
||||||
|
}, [openPositions]);
|
||||||
|
|
||||||
function build(unit: OrgUnitNode): ChartNode {
|
// Mirrors the JSX walk below into the generic ChartNode shape for graph
|
||||||
const key = `unit-${unit.id}`;
|
// mode — same synthetic ids ("root", div-*, dept-*, team-*, teamKey-title)
|
||||||
const chief = chiefOf.get(unit.id);
|
// the list already uses as `expanded` keys, so one Set drives both.
|
||||||
|
const chartTree = useMemo<ChartNode[]>(() => {
|
||||||
|
if (!ceo) return [];
|
||||||
|
|
||||||
const subUnits = (childUnits.get(unit.id) ?? []).map(build);
|
function buildTeam(team: OrgTeam): ChartNode {
|
||||||
|
const teamKey = `team-${team.id}`;
|
||||||
|
const lead = teamLeadByTeam.get(team.id);
|
||||||
|
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
|
||||||
|
const openForTeam = openByTeam.get(team.id) ?? [];
|
||||||
|
|
||||||
// Nach Tätigkeit gruppiert: dreissig Zeilen „Maschinenbediener:in" sagen
|
const titleGroups: ChartNode[] = Array.from(icsByTitle.entries()).map(([title, people]) => ({
|
||||||
// weniger als eine Zeile „Maschinenbediener:in — 30x besetzt".
|
id: `${teamKey}-${title}`,
|
||||||
const byTitle = new Map<string, OrgEmployee[]>();
|
kind: "group",
|
||||||
for (const e of staffOf.get(unit.id) ?? []) {
|
|
||||||
const list = byTitle.get(e.job_title) ?? [];
|
|
||||||
list.push(e);
|
|
||||||
byTitle.set(e.job_title, list);
|
|
||||||
}
|
|
||||||
const titleGroups: ChartNode[] = Array.from(byTitle.entries())
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b, "de"))
|
|
||||||
.map(([title, people]) => ({
|
|
||||||
id: `${key}-job-${title}`,
|
|
||||||
kind: "group" as const,
|
|
||||||
label: title,
|
label: title,
|
||||||
sublabel: `${people.length}x besetzt`,
|
sublabel: `${people.length}x besetzt`,
|
||||||
children: people
|
children: people.map((p) => ({
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.last_name.localeCompare(b.last_name, "de"))
|
|
||||||
.map((p) => ({
|
|
||||||
id: p.id,
|
id: p.id,
|
||||||
kind: "person" as const,
|
kind: "person",
|
||||||
label: `${p.first_name} ${p.last_name}`,
|
label: `${p.first_name} ${p.last_name}`,
|
||||||
href: `/employees/${p.id}`,
|
href: `/employees/${p.id}`,
|
||||||
avatar: { firstName: p.first_name, lastName: p.last_name },
|
avatar: { firstName: p.first_name, lastName: p.last_name },
|
||||||
absent: p.absent,
|
|
||||||
badge: p.absent ? (p.absence_type ?? "Abwesend") : undefined,
|
|
||||||
children: [],
|
children: [],
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const vacancyNodes: ChartNode[] = (vacantOf.get(unit.id) ?? [])
|
const vacancyNodes: ChartNode[] = openForTeam.map((p) => ({
|
||||||
.filter((v) => !v.is_chief) // die Leitungsvakanz steht schon in der Kopfzeile
|
id: `vac-${p.id}`,
|
||||||
.sort((a, b) => a.position_number.localeCompare(b.position_number))
|
kind: "vacancy",
|
||||||
.map((v) => ({
|
label: `${p.position_number} · ${p.title}`,
|
||||||
id: `vac-${v.position_id}`,
|
|
||||||
kind: "vacancy" as const,
|
|
||||||
label: `+ ${v.position_number} · ${v.job_title}`,
|
|
||||||
href: "/positions",
|
href: "/positions",
|
||||||
vacant: true,
|
|
||||||
children: [],
|
children: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: key,
|
id: teamKey,
|
||||||
kind: "role",
|
kind: "role",
|
||||||
label: `${unit.org_number} · ${unit.name}`,
|
label: `Teamleitung ${team.name}`,
|
||||||
sublabel: chief
|
sublabel: lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant",
|
||||||
? `Leitung: ${chief.first_name} ${chief.last_name}${chief.absent ? " (abwesend)" : ""}`
|
vacant: !lead,
|
||||||
: "Leitung vakant",
|
children: [...titleGroups, ...vacancyNodes],
|
||||||
vacant: !chief,
|
|
||||||
children: [...subUnits, ...titleGroups, ...vacancyNodes],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return (childUnits.get(null) ?? []).map(build);
|
function buildDept(dept: OrgDepartment): ChartNode {
|
||||||
|
return {
|
||||||
|
id: `dept-${dept.id}`,
|
||||||
|
kind: "group",
|
||||||
|
label: `${dept.org_number} · ${dept.name}`,
|
||||||
|
children: teams.filter((t) => t.department_id === dept.id).map(buildTeam),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDivision(div: OrgDivision): ChartNode {
|
||||||
|
const head = divisionHeadByDivision.get(div.id);
|
||||||
|
return {
|
||||||
|
id: `div-${div.id}`,
|
||||||
|
kind: "role",
|
||||||
|
label: `Bereichsleitung ${div.name}`,
|
||||||
|
sublabel: head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant",
|
||||||
|
vacant: !head,
|
||||||
|
children: departments.filter((d) => d.division_id === div.id).map(buildDept),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "root",
|
||||||
|
kind: "role",
|
||||||
|
label: "Geschäftsführung",
|
||||||
|
sublabel: `besetzt: ${ceo.first_name} ${ceo.last_name}`,
|
||||||
|
children: divisions.map(buildDivision),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [ceo, divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle, openByTeam, divisions, departments, teams]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
|
||||||
|
</div>
|
||||||
|
{mode === "graph" ? (
|
||||||
|
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} />
|
||||||
|
) : (
|
||||||
|
<div className="rounded border border-border bg-white p-4">
|
||||||
|
{ceo && (
|
||||||
|
<TreeRow depth={0} expandable expandedNow={expanded.has("root")} onToggle={() => toggle("root")}>
|
||||||
|
<span className="text-sm font-semibold text-ink">Geschäftsführung</span>
|
||||||
|
<span className="text-xs text-ink-muted">
|
||||||
|
besetzt: {ceo.first_name} {ceo.last_name}
|
||||||
|
</span>
|
||||||
|
</TreeRow>
|
||||||
|
)}
|
||||||
|
{expanded.has("root") &&
|
||||||
|
divisions.map((div) => {
|
||||||
|
const head = divisionHeadByDivision.get(div.id);
|
||||||
|
const divKey = `div-${div.id}`;
|
||||||
|
return (
|
||||||
|
<div key={div.id}>
|
||||||
|
<TreeRow depth={1} expandable expandedNow={expanded.has(divKey)} onToggle={() => toggle(divKey)}>
|
||||||
|
<span className="text-sm font-semibold text-ink">Bereichsleitung {div.name}</span>
|
||||||
|
<span className="text-xs text-ink-muted">{head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant"}</span>
|
||||||
|
</TreeRow>
|
||||||
|
{expanded.has(divKey) &&
|
||||||
|
departments
|
||||||
|
.filter((d) => d.division_id === div.id)
|
||||||
|
.map((dept) => {
|
||||||
|
const deptKey = `dept-${dept.id}`;
|
||||||
|
return (
|
||||||
|
<div key={dept.id}>
|
||||||
|
<TreeRow depth={2} expandable expandedNow={expanded.has(deptKey)} onToggle={() => toggle(deptKey)}>
|
||||||
|
<span className="text-sm text-ink">
|
||||||
|
{dept.org_number} · {dept.name}
|
||||||
|
</span>
|
||||||
|
</TreeRow>
|
||||||
|
{expanded.has(deptKey) &&
|
||||||
|
teams
|
||||||
|
.filter((t) => t.department_id === dept.id)
|
||||||
|
.map((team) => {
|
||||||
|
const teamKey = `team-${team.id}`;
|
||||||
|
const lead = teamLeadByTeam.get(team.id);
|
||||||
|
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
|
||||||
|
const openForTeam = openByTeam.get(team.id) ?? [];
|
||||||
|
return (
|
||||||
|
<div key={team.id}>
|
||||||
|
<TreeRow depth={3} expandable expandedNow={expanded.has(teamKey)} onToggle={() => toggle(teamKey)}>
|
||||||
|
<span className="text-sm text-ink">Teamleitung {team.name}</span>
|
||||||
|
<span className="text-xs text-ink-muted">
|
||||||
|
{lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant"}
|
||||||
|
</span>
|
||||||
|
</TreeRow>
|
||||||
|
{expanded.has(teamKey) && (
|
||||||
|
<>
|
||||||
|
{Array.from(icsByTitle.entries()).map(([title, people]) => {
|
||||||
|
const groupKey = `${teamKey}-${title}`;
|
||||||
|
return (
|
||||||
|
<div key={title}>
|
||||||
|
<TreeRow
|
||||||
|
depth={4}
|
||||||
|
expandable={people.length > 0}
|
||||||
|
expandedNow={expanded.has(groupKey)}
|
||||||
|
onToggle={() => toggle(groupKey)}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-ink">{title}</span>
|
||||||
|
<span className="text-xs text-ink-muted">{people.length}x besetzt</span>
|
||||||
|
</TreeRow>
|
||||||
|
{expanded.has(groupKey) &&
|
||||||
|
people.map((p) => (
|
||||||
|
<div key={p.id} className="py-1" style={{ paddingLeft: 5 * 24 + 8 }}>
|
||||||
|
<Link href={`/employees/${p.id}`} className="text-sm text-ink-body hover:text-brand-700 hover:underline">
|
||||||
|
{p.first_name} {p.last_name}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{openForTeam.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p.id}
|
||||||
|
href="/positions"
|
||||||
|
className="block border-l-2 border-dashed border-brand-200 py-1 text-sm text-brand-700 hover:underline"
|
||||||
|
style={{ paddingLeft: 4 * 24 + 8 }}
|
||||||
|
>
|
||||||
|
+ {p.position_number} · {p.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
404
components/orgchart/ReorgWorkbench.tsx
Normal file
404
components/orgchart/ReorgWorkbench.tsx
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { RotateCcw, X } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { applyReorg, undoReorg, type ReorgMovePayload } from "@/actions/reorg";
|
||||||
|
import { Button } from "@/components/ui/Button";
|
||||||
|
import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||||
|
import { Lookup } from "@/components/ui/Lookup";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import { fmtDate } from "@/lib/format";
|
||||||
|
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
|
||||||
|
|
||||||
|
type ChangeKind = "emp" | "team" | "abt" | "dept";
|
||||||
|
|
||||||
|
type PendingMove = {
|
||||||
|
id: string;
|
||||||
|
kind: ChangeKind;
|
||||||
|
label: string;
|
||||||
|
employeeIds: string[];
|
||||||
|
targetTeamId: string;
|
||||||
|
targetTeamLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_LABELS: Record<ChangeKind, string> = {
|
||||||
|
emp: "Mitarbeiter:in(nen)",
|
||||||
|
team: "Ganzes Team",
|
||||||
|
abt: "Ganze Abteilung",
|
||||||
|
dept: "Ganzer Bereich",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReorgWorkbenchProps = {
|
||||||
|
employees: OrgEmployee[];
|
||||||
|
divisions: OrgDivision[];
|
||||||
|
departments: OrgDepartment[];
|
||||||
|
teams: OrgTeam[];
|
||||||
|
reorgScenarios: ReorgScenarioSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ReorgWorkbench({ employees, divisions, departments, teams, reorgScenarios }: ReorgWorkbenchProps) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [effectiveDate, setEffectiveDate] = useState("");
|
||||||
|
const [changeType, setChangeType] = useState<ChangeKind>("emp");
|
||||||
|
const [selectedEmployees, setSelectedEmployees] = useState<OrgEmployee[]>([]);
|
||||||
|
const [sourceTeamId, setSourceTeamId] = useState("");
|
||||||
|
const [sourceDeptId, setSourceDeptId] = useState("");
|
||||||
|
const [sourceDivisionId, setSourceDivisionId] = useState("");
|
||||||
|
const [targetDivisionId, setTargetDivisionId] = useState("");
|
||||||
|
const [targetTeamId, setTargetTeamId] = useState("");
|
||||||
|
const [pendingMoves, setPendingMoves] = useState<PendingMove[]>([]);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [undoingId, setUndoingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const divisionById = useMemo(() => new Map(divisions.map((d) => [d.id, d])), [divisions]);
|
||||||
|
const departmentById = useMemo(() => new Map(departments.map((d) => [d.id, d])), [departments]);
|
||||||
|
const teamById = useMemo(() => new Map(teams.map((t) => [t.id, t])), [teams]);
|
||||||
|
const teamDivisionId = useMemo(() => {
|
||||||
|
const deptDivision = new Map(departments.map((d) => [d.id, d.division_id]));
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const t of teams) m.set(t.id, deptDivision.get(t.department_id) ?? "");
|
||||||
|
return m;
|
||||||
|
}, [teams, departments]);
|
||||||
|
const teamsInTargetDivision = useMemo(
|
||||||
|
() => teams.filter((t) => teamDivisionId.get(t.id) === targetDivisionId),
|
||||||
|
[teams, teamDivisionId, targetDivisionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
async function searchLocalEmployees(query: string): Promise<OrgEmployee[]> {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (q.length < 2) return [];
|
||||||
|
return employees
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
!selectedEmployees.some((s) => s.id === e.id) &&
|
||||||
|
(`${e.first_name} ${e.last_name}`.toLowerCase().includes(q) || e.job_title.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
.slice(0, 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEmployeeIds(): string[] {
|
||||||
|
if (changeType === "emp") return selectedEmployees.map((e) => e.id);
|
||||||
|
if (changeType === "team") return employees.filter((e) => e.team_id === sourceTeamId).map((e) => e.id);
|
||||||
|
if (changeType === "abt") {
|
||||||
|
const teamIds = new Set(teams.filter((t) => t.department_id === sourceDeptId).map((t) => t.id));
|
||||||
|
return employees.filter((e) => e.team_id && teamIds.has(e.team_id)).map((e) => e.id);
|
||||||
|
}
|
||||||
|
return employees.filter((e) => e.division_id === sourceDivisionId).map((e) => e.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(): string {
|
||||||
|
if (changeType === "emp") return `${selectedEmployees.length} Mitarbeiter:in(nen)`;
|
||||||
|
if (changeType === "team") return `Team ${teamById.get(sourceTeamId)?.name ?? ""}`;
|
||||||
|
if (changeType === "abt") return `Abteilung ${departmentById.get(sourceDeptId)?.name ?? ""}`;
|
||||||
|
return `Bereich ${divisionById.get(sourceDivisionId)?.name ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddMove() {
|
||||||
|
const employeeIds = resolveEmployeeIds();
|
||||||
|
if (employeeIds.length === 0) {
|
||||||
|
showToast("Keine Mitarbeiter:innen in der Auswahl gefunden.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!targetTeamId) {
|
||||||
|
showToast("Bitte ein Ziel-Team wählen.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetTeam = teamById.get(targetTeamId);
|
||||||
|
setPendingMoves((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
kind: changeType,
|
||||||
|
label: sourceLabel(),
|
||||||
|
employeeIds,
|
||||||
|
targetTeamId,
|
||||||
|
targetTeamLabel: targetTeam?.name ?? "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setSelectedEmployees([]);
|
||||||
|
setSourceTeamId("");
|
||||||
|
setSourceDeptId("");
|
||||||
|
setSourceDivisionId("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeMove(id: string) {
|
||||||
|
setPendingMoves((prev) => prev.filter((m) => m.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const divisionBefore = useMemo(() => {
|
||||||
|
const m = new Map<string, number>();
|
||||||
|
for (const e of employees) m.set(e.division_id, (m.get(e.division_id) ?? 0) + 1);
|
||||||
|
return m;
|
||||||
|
}, [employees]);
|
||||||
|
|
||||||
|
const divisionDelta = useMemo(() => {
|
||||||
|
const m = new Map<string, number>();
|
||||||
|
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||||||
|
for (const move of pendingMoves) {
|
||||||
|
const targetDivId = teamDivisionId.get(move.targetTeamId);
|
||||||
|
for (const empId of move.employeeIds) {
|
||||||
|
const emp = employeeById.get(empId);
|
||||||
|
if (!emp || !targetDivId || emp.division_id === targetDivId) continue;
|
||||||
|
m.set(emp.division_id, (m.get(emp.division_id) ?? 0) - 1);
|
||||||
|
m.set(targetDivId, (m.get(targetDivId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [pendingMoves, employees, teamDivisionId]);
|
||||||
|
|
||||||
|
async function handleApply() {
|
||||||
|
if (!name || !effectiveDate || pendingMoves.length === 0) {
|
||||||
|
showToast("Bitte Name, Datum und mindestens eine Änderung angeben.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setApplying(true);
|
||||||
|
const moves: ReorgMovePayload[] = pendingMoves.map((m) => ({
|
||||||
|
kind: m.kind,
|
||||||
|
label: m.label,
|
||||||
|
employee_ids: m.employeeIds,
|
||||||
|
target_team_id: m.targetTeamId,
|
||||||
|
}));
|
||||||
|
const result = await applyReorg({ name, effective_date: effectiveDate, moves });
|
||||||
|
setApplying(false);
|
||||||
|
if (result.success) {
|
||||||
|
showToast("Reorganisation durchgeführt.");
|
||||||
|
setPendingMoves([]);
|
||||||
|
setName("");
|
||||||
|
setEffectiveDate("");
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
showToast(result.error ?? "Fehler bei der Reorganisation.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUndo(scenarioId: string) {
|
||||||
|
setUndoingId(scenarioId);
|
||||||
|
const result = await undoReorg({ scenario_id: scenarioId });
|
||||||
|
setUndoingId(null);
|
||||||
|
if (result.success) {
|
||||||
|
showToast("Reorganisation rückgängig gemacht.");
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
showToast(result.error ?? "Fehler beim Rückgängigmachen.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded border border-border bg-white p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<TextField label="Name der Reorganisation" required value={name} onChange={setName} />
|
||||||
|
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset className="mt-4">
|
||||||
|
<legend className="sr-only">Art der Änderung</legend>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(Object.keys(KIND_LABELS) as ChangeKind[]).map((kind) => (
|
||||||
|
<button
|
||||||
|
key={kind}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={changeType === kind}
|
||||||
|
onClick={() => setChangeType(kind)}
|
||||||
|
className={`rounded px-3 py-1.5 text-sm font-semibold focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||||||
|
changeType === kind ? "bg-brand-500 text-white" : "border border-border text-ink-body hover:bg-surface"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{KIND_LABELS[kind]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
{changeType === "emp" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Field label="Quelle: Mitarbeiter:innen">
|
||||||
|
{(p) => (
|
||||||
|
<Lookup<OrgEmployee>
|
||||||
|
{...p}
|
||||||
|
placeholder="Mitarbeiter:in suchen…"
|
||||||
|
onSearch={searchLocalEmployees}
|
||||||
|
onSelect={(e) => setSelectedEmployees((prev) => [...prev, e])}
|
||||||
|
renderResult={(e) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-ink">
|
||||||
|
{e.first_name} {e.last_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-muted">{e.job_title}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
{selectedEmployees.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{selectedEmployees.map((e) => (
|
||||||
|
<span key={e.id} className="flex items-center gap-1 rounded-full bg-brand-100 px-2.5 py-1 text-xs font-semibold text-brand-700">
|
||||||
|
{e.first_name} {e.last_name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`${e.first_name} ${e.last_name} aus der Auswahl entfernen`}
|
||||||
|
onClick={() => setSelectedEmployees((prev) => prev.filter((s) => s.id !== e.id))}
|
||||||
|
className="rounded focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{changeType === "team" && (
|
||||||
|
<SelectField
|
||||||
|
label="Quelle: Team"
|
||||||
|
value={sourceTeamId}
|
||||||
|
onChange={setSourceTeamId}
|
||||||
|
placeholder="Team wählen…"
|
||||||
|
options={teams.map((t) => ({ value: t.id, label: t.name }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{changeType === "abt" && (
|
||||||
|
<SelectField
|
||||||
|
label="Quelle: Abteilung"
|
||||||
|
value={sourceDeptId}
|
||||||
|
onChange={setSourceDeptId}
|
||||||
|
placeholder="Abteilung wählen…"
|
||||||
|
options={departments.map((d) => ({ value: d.id, label: d.name }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{changeType === "dept" && (
|
||||||
|
<SelectField
|
||||||
|
label="Quelle: Bereich"
|
||||||
|
value={sourceDivisionId}
|
||||||
|
onChange={setSourceDivisionId}
|
||||||
|
placeholder="Bereich wählen…"
|
||||||
|
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<SelectField
|
||||||
|
label="Ziel-Bereich"
|
||||||
|
required
|
||||||
|
value={targetDivisionId}
|
||||||
|
onChange={(v) => {
|
||||||
|
setTargetDivisionId(v);
|
||||||
|
setTargetTeamId("");
|
||||||
|
}}
|
||||||
|
placeholder="Ziel-Bereich wählen…"
|
||||||
|
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label="Ziel-Team"
|
||||||
|
required
|
||||||
|
value={targetTeamId}
|
||||||
|
onChange={setTargetTeamId}
|
||||||
|
placeholder="Ziel-Team wählen…"
|
||||||
|
options={teamsInTargetDivision.map((t) => ({ value: t.id, label: t.name }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="secondary" onClick={handleAddMove} className="mt-4">
|
||||||
|
+ Zur Reorganisation hinzufügen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pendingMoves.length > 0 && (
|
||||||
|
<div className="rounded border border-border bg-white p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-bold text-ink">Geplante Änderungen ({pendingMoves.length})</h2>
|
||||||
|
<ul className="mb-4 flex flex-col divide-y divide-border">
|
||||||
|
{pendingMoves.map((m) => (
|
||||||
|
<li key={m.id} className="flex items-center justify-between py-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="mr-2 rounded-full bg-purple-bg px-2 py-0.5 text-xs font-semibold text-purple-text">{KIND_LABELS[m.kind]}</span>
|
||||||
|
<span className="text-ink">
|
||||||
|
{m.label} → {m.targetTeamLabel}
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-xs text-ink-muted">({m.employeeIds.length} Mitarbeiter:innen)</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="icon"
|
||||||
|
onClick={() => removeMove(m.id)}
|
||||||
|
aria-label={`${m.label} aus der Reorganisation entfernen`}
|
||||||
|
className="hover:!text-danger-solid"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Auswirkung auf Headcount</h3>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs text-ink-muted">
|
||||||
|
<th className="py-1 pr-3">Bereich</th>
|
||||||
|
<th className="py-1 pr-3">Vorher</th>
|
||||||
|
<th className="py-1 pr-3">Nachher</th>
|
||||||
|
<th className="py-1 pr-3">Δ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from(divisionDelta.entries())
|
||||||
|
.filter(([, delta]) => delta !== 0)
|
||||||
|
.map(([divId, delta]) => {
|
||||||
|
const before = divisionBefore.get(divId) ?? 0;
|
||||||
|
return (
|
||||||
|
<tr key={divId} className="border-t border-border">
|
||||||
|
<td className="py-1.5 pr-3 text-ink">{divisionById.get(divId)?.name}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-ink-body">{before}</td>
|
||||||
|
<td className="py-1.5 pr-3 text-ink-body">{before + delta}</td>
|
||||||
|
<td className={`py-1.5 pr-3 font-semibold ${delta > 0 ? "text-success-text" : "text-danger-text"}`}>
|
||||||
|
{delta > 0 ? `+${delta}` : delta}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => setPendingMoves([])}>
|
||||||
|
Verwerfen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleApply} pending={applying}>
|
||||||
|
Reorganisation durchführen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{reorgScenarios.length > 0 && (
|
||||||
|
<div className="rounded border border-border bg-white p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-bold text-ink">↩ Durchgeführte Reorganisationen – rückgängig machbar</h2>
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{reorgScenarios.map((s) => (
|
||||||
|
<li key={s.id} className="flex items-center justify-between py-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-ink">{s.name}</span>
|
||||||
|
<span className="ml-2 text-xs text-ink-muted">
|
||||||
|
wirksam ab {fmtDate(s.effective_date)} · durchgeführt am {fmtDate(s.applied_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => handleUndo(s.id)} pending={undoingId === s.id}>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
Rückgängig machen
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,30 +15,16 @@ export type OrgEmployee = {
|
|||||||
/** This person is on a long-term absence as of the chart's date. */
|
/** This person is on a long-term absence as of the chart's date. */
|
||||||
absent: boolean;
|
absent: boolean;
|
||||||
absence_type: string | null;
|
absence_type: string | null;
|
||||||
/** Die Einheit der Planstelle — die einzige Verortung, die es noch gibt. */
|
team_id: string | null;
|
||||||
org_unit_id: string;
|
division_id: string;
|
||||||
/** Diese Planstelle führt ihre Einheit (SAP-OM: A012). */
|
is_lead: boolean;
|
||||||
is_chief: boolean;
|
org_level: number;
|
||||||
position_id: string;
|
|
||||||
position_number: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Eine Planstelle, die am Stichtag niemand innehat. */
|
export type OrgDivision = { id: string; org_number: string; name: string };
|
||||||
export type OrgVacancy = {
|
export type OrgDepartment = { id: string; org_number: string; name: string; division_id: string };
|
||||||
position_id: string;
|
export type OrgTeam = { id: string; org_number: string; name: string; department_id: string };
|
||||||
position_number: string;
|
export type ReorgScenarioSummary = { id: string; name: string; effective_date: string; applied: boolean; applied_at: string | null };
|
||||||
job_title: string;
|
|
||||||
org_unit_id: string;
|
|
||||||
is_chief: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OrgUnitNode = {
|
|
||||||
id: string;
|
|
||||||
org_number: string;
|
|
||||||
name: string;
|
|
||||||
parent_id: string | null;
|
|
||||||
unit_type: "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generic tree shape both EmployeeTree and PositionTree map their own data
|
// Generic tree shape both EmployeeTree and PositionTree map their own data
|
||||||
// into for the graphical (React Flow + Dagre) view — see GraphOrgChart.
|
// into for the graphical (React Flow + Dagre) view — see GraphOrgChart.
|
||||||
|
|||||||
@@ -2,64 +2,51 @@
|
|||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createPosition } from "@/actions/positions";
|
import { createPosition, searchSuperiors, type SuperiorSearchResult } from "@/actions/positions";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { SelectField, TextField } from "@/components/ui/Field";
|
import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||||
|
import { Lookup } from "@/components/ui/Lookup";
|
||||||
import { Modal } from "@/components/ui/Modal";
|
import { Modal } from "@/components/ui/Modal";
|
||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { todayIso } from "@/lib/format";
|
import { todayIso } from "@/lib/format";
|
||||||
|
import type { Database } from "@/lib/supabase/types";
|
||||||
|
|
||||||
export type UnitOption = { id: string; name: string; unit_type: string; depth: number; hasChief: boolean };
|
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||||
|
|
||||||
// Eine Planstelle gehört zu genau einer Organisationseinheit — mehr braucht
|
export function CreatePositionModal({ open, onClose, teams }: { open: boolean; onClose: () => void; teams: Team[] }) {
|
||||||
// es nicht. Vorher musste hier eine vorgesetzte Person gesucht werden; die
|
|
||||||
// ergibt sich jetzt aus der Einheit, und die Frage kann gar nicht mehr falsch
|
|
||||||
// beantwortet werden.
|
|
||||||
export function CreatePositionModal({
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
units,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
units: UnitOption[];
|
|
||||||
}) {
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [jobTitle, setJobTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [orgUnitId, setOrgUnitId] = useState("");
|
const [isLead, setIsLead] = useState(false);
|
||||||
const [isChief, setIsChief] = useState(false);
|
const [superior, setSuperior] = useState<SuperiorSearchResult | null>(null);
|
||||||
|
const [teamId, setTeamId] = useState("");
|
||||||
const [validFrom, setValidFrom] = useState(todayIso);
|
const [validFrom, setValidFrom] = useState(todayIso);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
const unit = units.find((u) => u.id === orgUnitId);
|
|
||||||
// Der Unique-Index lässt nur eine gültige Leitung je Einheit zu. Das hier
|
|
||||||
// vorwegzunehmen erspart eine Fehlermeldung, die in der Oberfläche nichts
|
|
||||||
// erklärt.
|
|
||||||
const chiefTaken = Boolean(unit?.hasChief);
|
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
setJobTitle("");
|
setTitle("");
|
||||||
setOrgUnitId("");
|
setIsLead(false);
|
||||||
setIsChief(false);
|
setSuperior(null);
|
||||||
|
setTeamId("");
|
||||||
setValidFrom(todayIso());
|
setValidFrom(todayIso());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!jobTitle || !orgUnitId || !validFrom) {
|
if (!title || !superior || !validFrom || (isLead && !teamId)) {
|
||||||
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPending(true);
|
setPending(true);
|
||||||
const result = await createPosition({
|
const result = await createPosition({
|
||||||
org_unit_id: orgUnitId,
|
title,
|
||||||
job_title: jobTitle,
|
superior_employee_id: superior.id,
|
||||||
is_chief: isChief && !chiefTaken,
|
is_lead: isLead,
|
||||||
|
team_id: isLead ? teamId : undefined,
|
||||||
valid_from: validFrom,
|
valid_from: validFrom,
|
||||||
});
|
});
|
||||||
setPending(false);
|
setPending(false);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast("Planstelle angelegt.");
|
showToast("Position ausgeschrieben.");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
onClose();
|
onClose();
|
||||||
reset();
|
reset();
|
||||||
@@ -72,46 +59,77 @@ export function CreatePositionModal({
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Planstelle anlegen"
|
title="Position ausschreiben"
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose}>
|
<Button variant="ghost" onClick={onClose}>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} pending={pending}>
|
<Button onClick={handleSubmit} pending={pending}>
|
||||||
Anlegen
|
Ausschreiben
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<SelectField
|
<TextField label="Titel" required value={title} onChange={setTitle} />
|
||||||
label="Organisationseinheit"
|
|
||||||
required
|
|
||||||
value={orgUnitId}
|
|
||||||
onChange={(v) => {
|
|
||||||
setOrgUnitId(v);
|
|
||||||
setIsChief(false);
|
|
||||||
}}
|
|
||||||
placeholder="Bitte wählen…"
|
|
||||||
options={units.map((u) => ({
|
|
||||||
value: u.id,
|
|
||||||
label: `${" ".repeat(u.depth * 3)}${u.name} (${u.unit_type})`,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Tätigkeit"
|
|
||||||
required
|
|
||||||
value={jobTitle}
|
|
||||||
onChange={setJobTitle}
|
|
||||||
hint="Bestehende Tätigkeiten werden wiederverwendet; ein neuer Name legt einen Katalogeintrag an."
|
|
||||||
/>
|
|
||||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||||
<input type="checkbox" checked={isChief} disabled={!unit || chiefTaken} onChange={(e) => setIsChief(e.target.checked)} />
|
<input
|
||||||
Leitungsplanstelle für diese Einheit
|
type="checkbox"
|
||||||
|
checked={isLead}
|
||||||
|
onChange={(e) => {
|
||||||
|
setIsLead(e.target.checked);
|
||||||
|
setSuperior(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
Führungsposition (Teamleitung)
|
||||||
</label>
|
</label>
|
||||||
{chiefTaken && (
|
{!superior ? (
|
||||||
<p className="-mt-2 text-xs text-ink-muted">Für {unit?.name} besteht bereits eine Leitungsplanstelle.</p>
|
<Field label={isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"} required>
|
||||||
|
{(p) => (
|
||||||
|
<Lookup<SuperiorSearchResult>
|
||||||
|
{...p}
|
||||||
|
placeholder="Name oder Titel…"
|
||||||
|
onSearch={(q) => searchSuperiors(q, isLead)}
|
||||||
|
onSelect={setSuperior}
|
||||||
|
renderResult={(r) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-ink">
|
||||||
|
{r.first_name} {r.last_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-muted">{r.job_title}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 block text-sm font-semibold text-ink">
|
||||||
|
{isLead ? "Übergeordnete Bereichsleitung" : "Übergeordnete Teamleitung"}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between rounded border border-border bg-surface p-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-ink">
|
||||||
|
{superior.first_name} {superior.last_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-muted">{superior.job_title}</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setSuperior(null)}>
|
||||||
|
Ändern
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLead && (
|
||||||
|
<SelectField
|
||||||
|
label="Zu leitendes Team"
|
||||||
|
required
|
||||||
|
value={teamId}
|
||||||
|
onChange={setTeamId}
|
||||||
|
placeholder="Bitte wählen…"
|
||||||
|
options={teams.map((t) => ({ value: t.id, label: t.name }))}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
|
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,16 +8,16 @@ import { Button } from "@/components/ui/Button";
|
|||||||
import { useToast } from "@/components/ui/Toast";
|
import { useToast } from "@/components/ui/Toast";
|
||||||
import { fmtDate, todayIso } from "@/lib/format";
|
import { fmtDate, todayIso } from "@/lib/format";
|
||||||
import type { OpenPositionResolved } from "@/lib/positions";
|
import type { OpenPositionResolved } from "@/lib/positions";
|
||||||
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal";
|
import { CreatePositionModal } from "./CreatePositionModal";
|
||||||
|
|
||||||
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
|
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
|
||||||
|
|
||||||
type PositionsPageClientProps = {
|
type PositionsPageClientProps = {
|
||||||
openPositions: OpenPositionWithDays[];
|
openPositions: OpenPositionWithDays[];
|
||||||
units: UnitOption[];
|
teams: { id: string; org_number: string; name: string; department_id: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PositionsPageClient({ openPositions, units }: PositionsPageClientProps) {
|
export function PositionsPageClient({ openPositions, teams }: PositionsPageClientProps) {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
@@ -29,7 +29,7 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
|
|||||||
const result = await deletePosition(id);
|
const result = await deletePosition(id);
|
||||||
setDeletingId(null);
|
setDeletingId(null);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showToast("Planstelle entfernt.");
|
showToast("Position gelöscht.");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
showToast(result.error ?? "Fehler beim Löschen.", "error");
|
showToast(result.error ?? "Fehler beim Löschen.", "error");
|
||||||
@@ -40,14 +40,14 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
|
|||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="rounded border border-border bg-white p-4">
|
<div className="rounded border border-border bg-white p-4">
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({openPositions.length})</h2>
|
<h2 className="text-sm font-bold text-ink">Offene Positionen ({openPositions.length})</h2>
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Planstelle anlegen
|
Position ausschreiben
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{openPositions.length === 0 ? (
|
{openPositions.length === 0 ? (
|
||||||
<p className="text-sm text-ink-muted">Derzeit ist jede Planstelle besetzt.</p>
|
<p className="text-sm text-ink-muted">Derzeit keine offenen Positionen.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{openPositions.map((p) => {
|
{openPositions.map((p) => {
|
||||||
@@ -60,7 +60,7 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
|
|||||||
variant="icon"
|
variant="icon"
|
||||||
onClick={() => handleDelete(p.id)}
|
onClick={() => handleDelete(p.id)}
|
||||||
pending={deletingId === p.id}
|
pending={deletingId === p.id}
|
||||||
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
|
aria-label={`Position ${p.title} löschen`}
|
||||||
className="-mr-1 -mt-1 hover:!text-danger-solid"
|
className="-mr-1 -mt-1 hover:!text-danger-solid"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
@@ -69,11 +69,7 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
|
|||||||
<div className="text-xs text-ink-muted">
|
<div className="text-xs text-ink-muted">
|
||||||
{p.position_number} · {p.orgLabel}
|
{p.position_number} · {p.orgLabel}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-ink-muted">
|
<div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div>
|
||||||
{p.is_chief ? "Leitungsplanstelle · " : ""}
|
|
||||||
seit {p.daysOpen} Tagen unbesetzt
|
|
||||||
</div>
|
|
||||||
{p.managerName && <div className="text-xs text-ink-muted">berichtet an {p.managerName}</div>}
|
|
||||||
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
|
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -82,7 +78,7 @@ export function PositionsPageClient({ openPositions, units }: PositionsPageClien
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} units={units} />
|
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BarChart3, Building2, History, LayoutGrid, Network, Upload, Users, X } from "lucide-react";
|
import { BarChart3, Building2, History, LayoutGrid, Network, Users, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
@@ -12,7 +12,6 @@ const NAV_ITEMS = [
|
|||||||
{ href: "/positions", label: "Positionen", icon: Building2 },
|
{ href: "/positions", label: "Positionen", icon: Building2 },
|
||||||
{ href: "/reports", label: "Berichte", icon: BarChart3 },
|
{ href: "/reports", label: "Berichte", icon: BarChart3 },
|
||||||
{ href: "/audit", label: "Audit-Log", icon: History },
|
{ href: "/audit", label: "Audit-Log", icon: History },
|
||||||
{ href: "/import", label: "Import", icon: Upload },
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function isActiveRoute(pathname: string, href: string): boolean {
|
export function isActiveRoute(pathname: string, href: string): boolean {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
# Ohne Build-Argumente: alles, was die Anwendung braucht — DATABASE_URL,
|
|
||||||
# AUTH_* — liest sie zur Laufzeit aus .env. Das Abbild ist damit für jede
|
|
||||||
# Umgebung dasselbe.
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
args:
|
||||||
|
# NEXT_PUBLIC_* vars are inlined into the browser bundle at build
|
||||||
|
# time, so they have to be passed here, not just in env_file below.
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
# Umstieg auf Azure — Sicherheitsarchitektur
|
|
||||||
|
|
||||||
Entwurf zur Abnahme. **Noch kein Code umgestellt.**
|
|
||||||
|
|
||||||
Ziel: Azure Database for PostgreSQL (Flexible Server), Anmeldung über Entra ID,
|
|
||||||
Supabase als Abhängigkeit entfernt.
|
|
||||||
|
|
||||||
## Ausgangslage, gemessen
|
|
||||||
|
|
||||||
| | Anzahl |
|
|
||||||
|---|---|
|
|
||||||
| RLS-Policies | 58 |
|
|
||||||
| `auth.uid()` / `auth.users` in Migrationen | 79 |
|
|
||||||
| Fremdschlüssel auf `auth.users` | 9 |
|
|
||||||
| Datenzugriffe in der App (`.from()`, `.rpc()`) | 50 |
|
|
||||||
| Dateien mit Supabase-Import | 10 |
|
|
||||||
|
|
||||||
Die Anwendung läuft mit dem **anon-Key** (`lib/supabase/server.ts`,
|
|
||||||
`client.ts`); der Service-Role-Key kommt nur in `lib/supabase/admin.ts` vor.
|
|
||||||
Die RLS-Policies sind damit die tatsächliche Sicherheitsgrenze — nicht der
|
|
||||||
Proxy und nicht der Anwendungscode.
|
|
||||||
|
|
||||||
## Der entscheidende Befund
|
|
||||||
|
|
||||||
`auth.uid()` erscheint 70-mal, aber für die Absicherung zählt genau **eine**
|
|
||||||
Stelle:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
create or replace function is_hr_user() returns boolean
|
|
||||||
language sql security definer stable as $$
|
|
||||||
select exists (
|
|
||||||
select 1 from profiles p
|
|
||||||
where p.id = auth.uid() and p.role = 'hr' and p.is_active = true
|
|
||||||
);
|
|
||||||
$$;
|
|
||||||
```
|
|
||||||
|
|
||||||
Alle 58 Policies rufen `is_hr_user()` auf. Wird hier die Herkunft der
|
|
||||||
Benutzerkennung ausgetauscht, **bleiben alle Policies unverändert gültig**.
|
|
||||||
Die Sicherheitsarchitektur wandert also *nicht* in den Anwendungscode — das
|
|
||||||
war meine Sorge bei Variante B, und sie ist ausgeräumt.
|
|
||||||
|
|
||||||
Die übrigen ~55 Vorkommen stehen in Mutations-RPCs (`insert into audit_log
|
|
||||||
values (auth.uid(), …)`) und sind eine mechanische Ersetzung.
|
|
||||||
|
|
||||||
## Zielarchitektur
|
|
||||||
|
|
||||||
### 1. Benutzertabelle statt `auth.users`
|
|
||||||
|
|
||||||
```sql
|
|
||||||
create table app_users (
|
|
||||||
id uuid primary key default gen_random_uuid(),
|
|
||||||
entra_object_id uuid not null unique, -- oid aus dem Entra-Token
|
|
||||||
email text not null,
|
|
||||||
created_at timestamptz not null default now()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
Die neun Fremdschlüssel zeigen künftig hierauf. `profiles.id` bleibt der
|
|
||||||
Schlüssel, an dem `role` und `is_active` hängen — die HR-Freischaltung
|
|
||||||
funktioniert unverändert.
|
|
||||||
|
|
||||||
### 2. Sitzungskontext statt `auth.uid()`
|
|
||||||
|
|
||||||
```sql
|
|
||||||
create or replace function current_app_user() returns uuid
|
|
||||||
language sql stable as $$
|
|
||||||
select nullif(current_setting('app.user_id', true), '')::uuid;
|
|
||||||
$$;
|
|
||||||
```
|
|
||||||
|
|
||||||
`auth.uid()` → `current_app_user()`, überall. `is_hr_user()` bleibt sonst
|
|
||||||
Wort für Wort gleich.
|
|
||||||
|
|
||||||
### 3. Der kritische Punkt: wie der Kontext gesetzt wird
|
|
||||||
|
|
||||||
**Hier entscheidet sich, ob die Migration sicher ist.**
|
|
||||||
|
|
||||||
Jeder Datenbankzugriff muss in einer Transaktion laufen, die zuerst
|
|
||||||
`set local app.user_id` ausführt:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
await tx.execute(sql`select set_config('app.user_id', ${userId}, true)`);
|
|
||||||
return tx.select()…;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Das dritte Argument `true` bedeutet *transaktionslokal*. Ohne Transaktion
|
|
||||||
bliebe die Einstellung an der Verbindung hängen — und die nächste Anfrage,
|
|
||||||
die dieselbe Verbindung aus dem Pool zieht, liefe **mit der Kennung des
|
|
||||||
vorherigen Benutzers**. Das ist genau die Art Fehler, die in einem Test nie
|
|
||||||
auffällt und im Betrieb Personaldaten quer über Benutzer hinweg preisgibt.
|
|
||||||
|
|
||||||
Deshalb: **kein direkter Zugriff auf den Pool.** Es gibt eine einzige
|
|
||||||
Zugriffsfunktion, die die Transaktion und `set_config` erzwingt, und eine
|
|
||||||
Lint-Regel, die den Import des Pools außerhalb dieser Datei verbietet.
|
|
||||||
Das muss strukturell unmöglich sein, nicht per Konvention.
|
|
||||||
|
|
||||||
Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne**
|
|
||||||
`BYPASSRLS`. Selbst wenn der Kontext fehlt, liefern die Policies dann nichts
|
|
||||||
zurück — statt alles.
|
|
||||||
|
|
||||||
### 4. Anmeldung
|
|
||||||
|
|
||||||
Entra ID über NextAuth (Azure-AD-Provider) oder MSAL. Nach der Validierung
|
|
||||||
des Tokens wird die `oid` auf `app_users.entra_object_id` abgebildet; existiert
|
|
||||||
kein Eintrag, wird einer angelegt — **ohne** `profiles`-Zeile, also ohne
|
|
||||||
Zugriff. Die Freischaltung bleibt ein bewusster Schritt, wie heute
|
|
||||||
(`is_active` ist per Vorgabe `false`).
|
|
||||||
|
|
||||||
Damit erledigt sich die SSO-Frage aus der IT-Liste mit.
|
|
||||||
|
|
||||||
### 5. Datenzugriff
|
|
||||||
|
|
||||||
PostgREST entfällt; die 50 Aufrufe werden auf Drizzle umgestellt. Die sechs
|
|
||||||
`.rpc()`-Aufrufe sind trivial (direkter Funktionsaufruf), die 44
|
|
||||||
`.from()`-Aufrufe sind Query-Builder-Umschreibungen.
|
|
||||||
|
|
||||||
Das handgeschriebene `lib/supabase/types.ts` entfällt: Drizzle erzeugt die
|
|
||||||
Typen aus dem Schema, womit auch der Schema-Drift-Prüfer überflüssig wird.
|
|
||||||
|
|
||||||
## Was bewusst gleich bleibt
|
|
||||||
|
|
||||||
- **Alle 58 RLS-Policies**, unverändert
|
|
||||||
- Das gesamte Schema samt Enums, Arrays, `jsonb`, PL/pgSQL, partiellen Indizes
|
|
||||||
- `pgcrypto` und `pg_trgm` (beide auf Azure freigegeben)
|
|
||||||
- Die Geschäftslogik in den RPCs
|
|
||||||
|
|
||||||
## Reihenfolge
|
|
||||||
|
|
||||||
1. `app_users`, `current_app_user()`, Fremdschlüssel umhängen — additiv, gegen die bestehende Datenbank testbar
|
|
||||||
2. Zugriffsschicht mit erzwungener Transaktion + `set_config`, plus Test, der den Kontextverlust nachweist
|
|
||||||
3. Entra-ID-Anmeldung
|
|
||||||
4. Die 50 Datenzugriffe umstellen
|
|
||||||
5. Supabase-Pakete entfernen
|
|
||||||
6. Umzug der Datenbank per `pg_dump`/`pg_restore`
|
|
||||||
|
|
||||||
Schritt 2 ist der einzige, bei dem ein Fehler still bleibt. Dafür braucht es
|
|
||||||
einen Test, der zwei Anfragen über dieselbe gepoolte Verbindung schickt und
|
|
||||||
prüft, dass die zweite die erste nicht sieht.
|
|
||||||
|
|
||||||
## Offene Fragen an die Kunden-IT
|
|
||||||
|
|
||||||
- Welcher Entra-Mandant, und wer legt die App-Registrierung an?
|
|
||||||
- Gruppenbasierte Freischaltung (Entra-Gruppe „HR") oder weiter manuell über `profiles.is_active`?
|
|
||||||
- Flexible Server: Version, Region, Netzwerkzugang (Private Endpoint oder Firewall-Regeln)?
|
|
||||||
- Wer betreibt und patcht?
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
# Anmeldung über Entra ID
|
|
||||||
|
|
||||||
Die Anwendung meldet ausschliesslich über Microsoft Entra ID an. Die
|
|
||||||
Sitzungsverwaltung macht **Auth.js** (`auth.ts`, `lib/auth/config.ts`) — es gibt
|
|
||||||
keinen Anmeldedienst eines Anbieters mehr dazwischen.
|
|
||||||
|
|
||||||
**Warum das trotzdem eine kleine Änderung ist:** die Anmeldung liefert nach wie
|
|
||||||
vor nur eine UUID. `profiles.id` trägt weiterhin `role` und `is_active`, und
|
|
||||||
damit bleiben `is_hr_user()` und alle 58 RLS-Policies unverändert gültig. Die
|
|
||||||
Sicherheitsgrenze wandert nicht in den Anwendungscode.
|
|
||||||
|
|
||||||
## Einrichtung im Entra-Mandanten
|
|
||||||
|
|
||||||
App-Registrierung, einmalig — angelegt im Mandanten *loudspring management GmbH*:
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| Name | Alpenwerk HR |
|
|
||||||
| Kontotypen | Nur ein Mandant |
|
|
||||||
| Umleitungs-URI (Web) | `https://<produktion>/api/auth/callback/microsoft-entra-id` |
|
|
||||||
| Anwendungs-ID (Client) | `<client-id>` |
|
|
||||||
| Verzeichnis-ID (Mandant) | `<tenant-id>` |
|
|
||||||
|
|
||||||
Die konkreten Werte stehen bewusst nicht hier, sondern in der
|
|
||||||
Übergabedokumentation. Sie sind zwar keine Geheimnisse — ohne Client-Geheimnis
|
|
||||||
gibt eine ID nichts her, und RLS greift ohnehin —, aber sie zeigen auf die echte
|
|
||||||
Umgebung, und dieses Repository wandert weiter als sie.
|
|
||||||
|
|
||||||
Die Umleitungs-URI zeigt jetzt auf die **Anwendung selbst**. Für die lokale
|
|
||||||
Entwicklung kommt `http://localhost:3000/api/auth/callback/microsoft-entra-id`
|
|
||||||
als zweite URI dazu; Entra erlaubt `http` nur für `localhost`.
|
|
||||||
|
|
||||||
Danach:
|
|
||||||
|
|
||||||
1. **Zertifikate & Geheimnisse** → neues Client-Geheimnis. Der *Wert* wird
|
|
||||||
gebraucht, nicht die Geheimnis-ID, und er ist nur einmal sichtbar.
|
|
||||||
2. **API-Berechtigungen** → `openid`, `profile`, `email` (Microsoft Graph,
|
|
||||||
delegiert), Administratorzustimmung erteilen. `User.Read` wird **nicht**
|
|
||||||
gebraucht: der eingebaute Anbieter von Auth.js fordert es an, um das
|
|
||||||
Profilbild aus dem Graph zu holen — `lib/auth/config.ts` schaltet beides ab.
|
|
||||||
3. **Tokenkonfiguration** → Gruppenanspruch, siehe unten.
|
|
||||||
|
|
||||||
## Konfiguration der Anwendung
|
|
||||||
|
|
||||||
Vier Werte, alle server-seitig — nichts davon landet im Browser-Bundle:
|
|
||||||
|
|
||||||
| Variable | Wert |
|
|
||||||
|---|---|
|
|
||||||
| `AUTH_SECRET` | `npx auth secret` oder `openssl rand -base64 32` |
|
|
||||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Anwendungs-ID (Client) |
|
|
||||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | der Wert aus „Zertifikate & Geheimnisse" |
|
|
||||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<tenant-id>/v2.0` |
|
|
||||||
|
|
||||||
Der Aussteller ist bei „Nur ein Mandant" **nicht optional**. Bleibt er leer,
|
|
||||||
benutzt Auth.js `common` — dann dürfte sich jedes Microsoft-Konto anmelden, auch
|
|
||||||
ein privates Outlook-Konto. Die Freischaltung über `profiles` fängt das zwar ab,
|
|
||||||
aber die Eingangstür soll erst gar nicht so weit offenstehen.
|
|
||||||
|
|
||||||
`AUTH_SECRET` verschlüsselt das Sitzungscookie. Ein Wechsel meldet alle ab — im
|
|
||||||
Ernstfall genau das gewünschte Mittel.
|
|
||||||
|
|
||||||
## Was bei der ersten Anmeldung passiert
|
|
||||||
|
|
||||||
1. Auth.js prüft das Token von Entra (`state`, `nonce`, Signatur, Aussteller).
|
|
||||||
2. Der Rückruf in `auth.ts` nimmt daraus die **`oid`** — nicht `sub`, nicht die
|
|
||||||
E-Mail. Die `oid` identifiziert dieselbe Person über Anwendungen hinweg und
|
|
||||||
überlebt Namens- und Adressänderungen.
|
|
||||||
3. `app_upsert_user(oid, email, name)` legt die `app_users`-Zeile an und liefert
|
|
||||||
die Kennung, die von da an in jeder Transaktion als `app.user_id` steht.
|
|
||||||
4. **Gibt es zu der Adresse bereits ein `profiles`-Eintrag, übernimmt die
|
|
||||||
Funktion dessen Kennung** statt eine neue zu vergeben. Das ist der Grund,
|
|
||||||
warum bestehende Zugänge nach der Umstellung weiterlaufen: Notizen,
|
|
||||||
Entwürfe und Protokolleinträge hängen an dieser ID.
|
|
||||||
|
|
||||||
Der Abgleich über die Adresse ist genau hier vertretbar und sonst nirgends: sie
|
|
||||||
kommt aus einem von Entra ausgestellten Token, nicht aus einem Formular. Wer sie
|
|
||||||
behauptet, hat sie bereits bewiesen.
|
|
||||||
|
|
||||||
## Freischaltung über die Entra-Gruppe
|
|
||||||
|
|
||||||
Wer sich anmeldet, hat damit **noch keinen Zugriff**. Zugriff hat, wer eine
|
|
||||||
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` besitzt.
|
|
||||||
|
|
||||||
### Woher der Gruppen-Anspruch kommt
|
|
||||||
|
|
||||||
Entra schickt Gruppen nur mit, wenn es in der Tokenkonfiguration eingestellt
|
|
||||||
ist. Zwei Varianten:
|
|
||||||
|
|
||||||
| Variante | Lizenz | Haken |
|
|
||||||
|---|---|---|
|
|
||||||
| Sicherheitsgruppen | frei | Schickt *alle* Sicherheitsgruppen mit. Ab etwa 200 Gruppen liefert Entra statt der Liste einen Verweis, und die Auswertung greift ins Leere. |
|
|
||||||
| Der Anwendung zugewiesene Gruppen | Entra ID P1 | Nur die zugewiesene Gruppe steht im Token. |
|
|
||||||
|
|
||||||
### Wo die Auswertung hingehört
|
|
||||||
|
|
||||||
In den `jwt`-Rückruf in `auth.ts`, neben `app_upsert_user()` — dort liegt
|
|
||||||
`profile.groups` aus dem ID-Token vor.
|
|
||||||
|
|
||||||
Das ist belastbar, und der Grund ist wichtig: das ID-Token ist von Entra
|
|
||||||
signiert und wurde von Auth.js gegen den Aussteller geprüft. Die angemeldete
|
|
||||||
Person kann seinen Inhalt nicht beeinflussen. (Unter GoTrue war dieselbe Stelle
|
|
||||||
eine Falle: `auth.users.raw_user_meta_data` war von der Person selbst
|
|
||||||
beschreibbar, und eine Freischaltung, die von dort gelesen hätte, wäre
|
|
||||||
selbstbedienbar gewesen.)
|
|
||||||
|
|
||||||
### Reihenfolge
|
|
||||||
|
|
||||||
Gebaut wird das erst, wenn feststeht, wie der Anspruch tatsächlich ankommt — das
|
|
||||||
hängt an der gewählten Variante und an der Konfiguration des Mandanten. Ablauf:
|
|
||||||
|
|
||||||
1. SSO in Betrieb nehmen, einmal anmelden.
|
|
||||||
2. Im `jwt`-Rückruf einmalig `console.log(profile)` — das zeigt die Ansprüche so,
|
|
||||||
wie der Mandant sie tatsächlich schickt.
|
|
||||||
3. Erst dann die Auswertung mit der konkreten Gruppen-ID schreiben.
|
|
||||||
|
|
||||||
Ohne Schritt 2 wäre sie geraten. Bis dahin wird `profiles` von Hand gepflegt.
|
|
||||||
|
|
||||||
### Was die Gruppe nicht kann
|
|
||||||
|
|
||||||
Die Mitgliedschaft steht im Token. Wer aus der Gruppe entfernt wird, verliert den
|
|
||||||
Zugriff deshalb **bei der nächsten Anmeldung**, nicht sofort. Für den sofortigen
|
|
||||||
Entzug bleibt `profiles.is_active = false` das Mittel — das wirkt beim nächsten
|
|
||||||
Datenbankzugriff, weil `is_hr_user()` die Spalte je Abfrage liest.
|
|
||||||
|
|
||||||
## Wer prüft was
|
|
||||||
|
|
||||||
| Stelle | Prüft | Wann |
|
|
||||||
|---|---|---|
|
|
||||||
| `proxy.ts` | Gibt es überhaupt eine Sitzung? | jede Anfrage |
|
|
||||||
| `app/(app)/layout.tsx` | `profiles.role` / `is_active` | jeder Seitenaufbau |
|
|
||||||
| `lib/auth/require-hr.ts` | dasselbe, für `/api/export/*` | jeder Aufruf |
|
|
||||||
| RLS-Policies | `is_hr_user()` | jede einzelne Abfrage |
|
|
||||||
|
|
||||||
Der Proxy prüft die HR-Rechte **nicht** — er hat keine Datenbankverbindung. Sie
|
|
||||||
in das Sitzungstoken zu schreiben wäre schneller gewesen und hätte eine
|
|
||||||
Behauptung eingefroren: eine entzogene Freischaltung wirkte dann erst mit dem
|
|
||||||
nächsten Token. Bei einer Personalanwendung ist das die falsche Richtung.
|
|
||||||
@@ -13,50 +13,6 @@ const eslintConfig = defineConfig([
|
|||||||
"build/**",
|
"build/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// ── Der Datenbankzugriff bleibt in einer Hand ───────────────────
|
|
||||||
//
|
|
||||||
// Die Zugriffsrechte hängen an einer Sitzungsvariablen, die nur innerhalb
|
|
||||||
// einer Transaktion gesetzt werden darf (siehe lib/db/index.ts). Wer den
|
|
||||||
// Pool direkt benutzt, umgeht das — und die Abfrage läuft dann mit dem
|
|
||||||
// Kontext, den die vorherige Anfrage auf derselben gepoolten Verbindung
|
|
||||||
// hinterlassen hat.
|
|
||||||
//
|
|
||||||
// Das muss strukturell unmöglich sein, nicht per Konvention: eine
|
|
||||||
// Vereinbarung überlebt den nächsten Termindruck nicht.
|
|
||||||
{
|
|
||||||
files: ["**/*.ts", "**/*.tsx"],
|
|
||||||
// Tests dürfen: sie werden nicht ausgeliefert, und einige prüfen gerade
|
|
||||||
// die Einstellungen des Pools — das geht nicht, ohne ihn anzusehen.
|
|
||||||
ignores: ["lib/db/**", "tests/**", "supabase/**", "scripts/**"],
|
|
||||||
rules: {
|
|
||||||
"no-restricted-imports": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
paths: [
|
|
||||||
{
|
|
||||||
name: "pg",
|
|
||||||
message:
|
|
||||||
"Kein direkter Pool-Zugriff. Abfragen laufen über withUser() aus lib/db — nur dort wird der Sitzungskontext transaktionslokal gesetzt.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "kysely",
|
|
||||||
importNames: ["Kysely"],
|
|
||||||
message:
|
|
||||||
"Keine zweite Kysely-Instanz. lib/db exportiert withUser(); die Instanz selbst bleibt privat, damit keine Abfrage ohne Kontext möglich ist.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
patterns: [
|
|
||||||
{
|
|
||||||
group: ["**/lib/db/pool", "**/db/pool"],
|
|
||||||
message:
|
|
||||||
"Der Pool ist absichtlich nicht exportiert. Über lib/db gehen — withUser() erzwingt die Transaktion.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id";
|
|
||||||
import type { NextAuthConfig } from "next-auth";
|
|
||||||
|
|
||||||
// Der Teil der Anmeldung, der **ohne Datenbank** auskommt.
|
|
||||||
//
|
|
||||||
// Das ist keine Stilfrage: proxy.ts läuft je nach Betriebsart in einer
|
|
||||||
// Umgebung ohne Node-Module — dort gibt es kein `pg` und keine Verbindung.
|
|
||||||
// Würde der Proxy die vollständige Konfiguration laden, zöge er die
|
|
||||||
// Zugriffsschicht mit hinein und liesse sich nicht mehr ausliefern. Deshalb
|
|
||||||
// hier nur Anbieter und Sitzungsregeln; alles, was die Datenbank berührt,
|
|
||||||
// steht in auth.ts.
|
|
||||||
|
|
||||||
/** Wie lange eine Anmeldung ohne erneuten Besuch bei Entra gilt. */
|
|
||||||
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 9; // ein Arbeitstag
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Der Aussteller — mit Abbruch statt Rückfall.
|
|
||||||
*
|
|
||||||
* Ohne diese Prüfung setzt Auth.js bei fehlender Variablen stillschweigend
|
|
||||||
* `https://login.microsoftonline.com/common/v2.0` ein. Das ist beim ersten
|
|
||||||
* Ausprobieren aufgefallen: die Weiterleitung ging tatsächlich nach
|
|
||||||
* `/common/oauth2/v2.0/authorize`, und damit hätte sich **jedes**
|
|
||||||
* Microsoft-Konto anmelden dürfen, auch ein privates.
|
|
||||||
*
|
|
||||||
* Aufgefallen wäre das im Betrieb sonst nicht — die Anmeldung funktioniert
|
|
||||||
* ja, nur eben für zu viele. Ein vergessener Wert in der Deployment-Umgebung
|
|
||||||
* muss deshalb den Start verhindern, nicht die Tür aufmachen.
|
|
||||||
*
|
|
||||||
* In der Entwicklung bleibt es bei einer Warnung: dort ist nichts
|
|
||||||
* konfiguriert, und ein Abbruch beim Laden des Moduls nähme auch die
|
|
||||||
* Anmeldeseite mit.
|
|
||||||
*/
|
|
||||||
function tenantIssuer(): string | undefined {
|
|
||||||
const issuer = process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER;
|
|
||||||
if (issuer) return issuer;
|
|
||||||
|
|
||||||
const hinweis =
|
|
||||||
"AUTH_MICROSOFT_ENTRA_ID_ISSUER fehlt. Ohne Mandanten-Aussteller fiele die " +
|
|
||||||
"Anmeldung auf /common/ zurück und stünde jedem Microsoft-Konto offen.";
|
|
||||||
if (process.env.NODE_ENV === "production") throw new Error(hinweis);
|
|
||||||
console.warn(`[auth] ${hinweis}`);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Die Konfiguration als **Funktion**, nicht als Objekt.
|
|
||||||
*
|
|
||||||
* Auth.js wertet die Funktionsform pro Anfrage aus. Das ist hier nötig, weil
|
|
||||||
* tenantIssuer() in der Produktion abbricht: als Objekt gebaut liefe die
|
|
||||||
* Prüfung schon beim Import — und `next build` importiert jedes Route-Modul,
|
|
||||||
* um die Seitendaten einzusammeln. Der Bau bräuchte dann die
|
|
||||||
* Anmeldekonfiguration der Zielumgebung, und ein Abbild liesse sich nicht
|
|
||||||
* mehr einmal bauen und überall ausliefern.
|
|
||||||
*/
|
|
||||||
export function authConfig(): NextAuthConfig {
|
|
||||||
return {
|
|
||||||
providers: [
|
|
||||||
MicrosoftEntraID({
|
|
||||||
clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID,
|
|
||||||
clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET,
|
|
||||||
issuer: tenantIssuer(),
|
|
||||||
|
|
||||||
// Der eingebaute Anbieter fordert zusätzlich `User.Read` an und holt
|
|
||||||
// damit das Profilbild aus dem Graph. Beides ist hier unerwünscht: eine
|
|
||||||
// Berechtigung, die niemand braucht, muss die Mandantenverwaltung
|
|
||||||
// trotzdem genehmigen — und das Bild landete base64-kodiert im
|
|
||||||
// Sitzungscookie, das dann in Teile zerfällt.
|
|
||||||
authorization: { params: { scope: "openid profile email" } },
|
|
||||||
profile(profile) {
|
|
||||||
return {
|
|
||||||
// Die `oid`, nicht `sub`: `sub` ist pro Anwendung verschieden, die
|
|
||||||
// `oid` identifiziert dieselbe Person über Anwendungen hinweg und
|
|
||||||
// überlebt Namens- und Adressänderungen.
|
|
||||||
id: profile.oid,
|
|
||||||
name: profile.name ?? null,
|
|
||||||
// `email` ist im Token ein optionaler Anspruch — je nach Mandant
|
|
||||||
// fehlt er. `preferred_username` bzw. `upn` tragen dann dieselbe
|
|
||||||
// Adresse. Ohne diesen Rückfall scheitert die Anmeldung in genau
|
|
||||||
// den Mandanten, die den Anspruch nicht ausdrücklich konfiguriert
|
|
||||||
// haben.
|
|
||||||
email: profile.email ?? profile.preferred_username ?? profile.upn ?? null,
|
|
||||||
image: null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Eigene Seite statt der von Auth.js mitgelieferten: die Anmeldung ist die
|
|
||||||
// erste Seite, die jemand sieht, und soll aussehen wie die Anwendung.
|
|
||||||
pages: { signIn: "/login", error: "/login" },
|
|
||||||
|
|
||||||
session: { strategy: "jwt", maxAge: SESSION_MAX_AGE_SECONDS },
|
|
||||||
|
|
||||||
callbacks: {
|
|
||||||
// Muss **hier** stehen und nicht in auth.ts, obwohl es nur eine
|
|
||||||
// Zuweisung ist.
|
|
||||||
//
|
|
||||||
// proxy.ts baut eine eigene Auth.js-Instanz aus genau dieser Datei. Lag
|
|
||||||
// die Zuordnung in auth.ts, bekäme der Proxy die Standard-Sitzung ohne
|
|
||||||
// `id`, hielte jede angemeldete Person für nicht angemeldet und
|
|
||||||
// schickte sie zurück auf /login — obwohl das Cookie längst gesetzt
|
|
||||||
// ist. Genau so ist die erste echte Anmeldung in einer Schleife
|
|
||||||
// gelandet: Konto angelegt, Sitzung gültig, und trotzdem kam man nicht
|
|
||||||
// hinein.
|
|
||||||
//
|
|
||||||
// Datenbank braucht das nicht, es liest nur aus dem entschlüsselten
|
|
||||||
// Token — deshalb darf es am Rand laufen.
|
|
||||||
session({ session, token }) {
|
|
||||||
if (token.uid) session.user.id = token.uid;
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// Hinter Reverse Proxy und Container-Netzwerk kommt der Host aus dem
|
|
||||||
// Header. Ohne das verweigert Auth.js in der Produktion den Dienst, weil
|
|
||||||
// es die Herkunft nicht bestätigen kann.
|
|
||||||
trustHost: true,
|
|
||||||
|
|
||||||
// Auf der Anmeldeseite steht bewusst nur eine allgemeine Meldung — der
|
|
||||||
// Grund ist fremdbestimmt und gehört nicht auf eine Seite, die echt
|
|
||||||
// aussieht. Im Serverprotokoll gehört er dagegen hin, und zwar
|
|
||||||
// vollständig: ohne das scheitert die Anmeldung lautlos, der Browser
|
|
||||||
// springt auf /login zurück, und es gibt nichts zu lesen ausser einer
|
|
||||||
// 302. Genau so ist die erste Anmeldung hier fehlgeschlagen.
|
|
||||||
logger: {
|
|
||||||
error(err) {
|
|
||||||
console.error("[auth] Fehler:", err);
|
|
||||||
const cause = (err as { cause?: unknown }).cause;
|
|
||||||
// Der eigentliche Grund steckt oft eine Ebene tiefer — bei einem
|
|
||||||
// Fehler in einem Rückruf ist die äussere Meldung nur „Read more at
|
|
||||||
// …/errors#callback-route-error".
|
|
||||||
if (cause) console.error("[auth] Ursache:", cause);
|
|
||||||
},
|
|
||||||
warn(code) {
|
|
||||||
console.warn("[auth] Warnung:", code);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import { withUser } from "@/lib/db";
|
|
||||||
import { currentUserId } from "./session";
|
|
||||||
|
|
||||||
// Route Handlers under /api/export/* are outside the App Router layout tree,
|
|
||||||
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
|
|
||||||
// re-establish that the caller is an active HR user itself. RLS is still the
|
|
||||||
// real boundary (an unauthorized session simply reads nothing); this exists
|
|
||||||
// so those routes answer 401/403 instead of handing back an empty workbook.
|
|
||||||
|
|
||||||
export type HrGate = { denied: NextResponse } | { userId: string };
|
|
||||||
|
|
||||||
export async function requireHrUser(): Promise<HrGate> {
|
|
||||||
const userId = await currentUserId();
|
|
||||||
if (!userId) return { denied: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }) };
|
|
||||||
|
|
||||||
const profile = await withUser(userId, (tx) =>
|
|
||||||
tx.selectFrom("profiles").select(["role", "is_active"]).where("id", "=", userId).executeTakeFirst()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (profile?.role !== "hr" || profile.is_active !== true) {
|
|
||||||
return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) };
|
|
||||||
}
|
|
||||||
return { userId };
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
|
|
||||||
// Der einzige Ort, an dem die Kennung der angemeldeten Person herkommt.
|
|
||||||
//
|
|
||||||
// Dass der Wechsel von GoTrue auf Auth.js eine Änderung an dieser Datei war
|
|
||||||
// und nicht an fünfzig Aufrufstellen, lag genau an dieser Bündelung: alles
|
|
||||||
// andere ruft `currentUserId()` auf und reicht den Wert an withUser() weiter.
|
|
||||||
//
|
|
||||||
// Der Wert ist app_users.id — nicht die `oid` von Entra. Die Zuordnung
|
|
||||||
// zwischen beiden macht app_upsert_user() bei der Anmeldung, und sie
|
|
||||||
// übernimmt für eine bereits bekannte Adresse die vorhandene profiles.id.
|
|
||||||
// Deshalb passt die Kennung weiterhin auf das, was app_current_user_id() in
|
|
||||||
// der Datenbank erwartet, und die 58 RLS-Policies merken vom Wechsel nichts.
|
|
||||||
|
|
||||||
export async function currentUserId(): Promise<string | null> {
|
|
||||||
const session = await auth();
|
|
||||||
return session?.user?.id ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wie currentUserId(), bricht aber ab, statt null zu liefern.
|
|
||||||
*
|
|
||||||
* Für Stellen, die ohne angemeldete Person keinen Sinn ergeben. Die
|
|
||||||
* Absicherung hängt trotzdem nicht daran: ohne Kontext geben die
|
|
||||||
* RLS-Policies nichts zurück, unabhängig davon, was der Anwendungscode tut.
|
|
||||||
*/
|
|
||||||
export async function requireUserId(): Promise<string> {
|
|
||||||
const id = await currentUserId();
|
|
||||||
if (!id) throw new Error("Nicht angemeldet.");
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { Kysely, PostgresDialect, sql, type Transaction } from "kysely";
|
|
||||||
import { getPool } from "./pool";
|
|
||||||
import type { Schema } from "./schema";
|
|
||||||
|
|
||||||
// Der einzige Weg an die Datenbank.
|
|
||||||
//
|
|
||||||
// ═══ Warum das keine gewöhnliche Datenbankschicht ist ═══
|
|
||||||
//
|
|
||||||
// Die Zugriffsrechte liegen in der Datenbank: 58 RLS-Policies rufen
|
|
||||||
// is_hr_user() auf, und das fragt seit der Umstellung nicht mehr Supabase,
|
|
||||||
// sondern `current_setting('app.user_id')` — eine Sitzungsvariable.
|
|
||||||
//
|
|
||||||
// Sitzungsvariablen hängen an der *Verbindung*, nicht an der Anfrage. Und
|
|
||||||
// Verbindungen kommen aus einem Pool. Wird die Variable ohne Transaktion
|
|
||||||
// gesetzt, bleibt sie an der Verbindung kleben, und die nächste Anfrage, die
|
|
||||||
// dieselbe Verbindung zieht, läuft mit der Kennung der vorherigen Person —
|
|
||||||
// quer über Benutzer hinweg, in einer Personaldatenbank.
|
|
||||||
//
|
|
||||||
// Das ist die Art Fehler, die in keinem Test auffällt, den man nicht
|
|
||||||
// absichtlich dafür schreibt (tests/integration/session-context.test.ts tut
|
|
||||||
// genau das). Deshalb:
|
|
||||||
//
|
|
||||||
// 1. Die Kysely-Instanz wird **nicht exportiert**. Wer abfragen will, muss
|
|
||||||
// durch withUser() — und das öffnet immer eine Transaktion.
|
|
||||||
// 2. `set_config(..., true)` — das dritte Argument bedeutet
|
|
||||||
// transaktionslokal. Mit `false` wäre die ganze Vorsichtsmassnahme
|
|
||||||
// wirkungslos.
|
|
||||||
// 3. Eine ESLint-Regel verbietet den Import von `pg` und `./pool`
|
|
||||||
// ausserhalb dieses Verzeichnisses.
|
|
||||||
//
|
|
||||||
// Zusätzlich verbindet sich die Anwendung mit einer Datenbankrolle **ohne**
|
|
||||||
// BYPASSRLS. Fehlt der Kontext trotz allem, liefern die Policies nichts
|
|
||||||
// zurück — nicht alles.
|
|
||||||
|
|
||||||
// Der Pool wird als Funktion übergeben, nicht als fertige Instanz: Kysely
|
|
||||||
// ruft sie erst bei der ersten Abfrage auf. So verlangt der Import dieses
|
|
||||||
// Moduls noch keine Zugangsdaten — siehe getPool().
|
|
||||||
const db = new Kysely<Schema>({
|
|
||||||
dialect: new PostgresDialect({ pool: async () => getPool() }),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Tx = Transaction<Schema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Führt `fn` im Namen der angegebenen Person aus.
|
|
||||||
*
|
|
||||||
* `userId` ist die app_users.id. Für nicht angemeldete Zugriffe null — dann
|
|
||||||
* greift keine Policy und es kommt nichts zurück, was auch richtig ist.
|
|
||||||
*/
|
|
||||||
export async function withUser<T>(userId: string | null, fn: (tx: Tx) => Promise<T>): Promise<T> {
|
|
||||||
return 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Für Abläufe ohne angemeldete Person — heute nur der nächtliche Lauf für
|
|
||||||
* fällige Änderungen.
|
|
||||||
*
|
|
||||||
* Bewusst kein privilegierter Zugang: die Verbindung benutzt dieselbe Rolle
|
|
||||||
* ohne BYPASSRLS. Was hier laufen darf, muss als SECURITY-DEFINER-Funktion
|
|
||||||
* in der Datenbank stehen und dort selbst prüfen, was es tut. Ein
|
|
||||||
* Dienstschlüssel, der RLS aushebelt, existiert nicht mehr.
|
|
||||||
*/
|
|
||||||
export async function asSystem<T>(fn: (tx: Tx) => Promise<T>): Promise<T> {
|
|
||||||
return withUser(null, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Für Migrations- und Wartungsskripte, die ausserhalb einer Anfrage laufen. */
|
|
||||||
export async function closeDb(): Promise<void> {
|
|
||||||
await db.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
export { sql };
|
|
||||||
102
lib/db/pool.ts
102
lib/db/pool.ts
@@ -1,102 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { Pool, types } from "pg";
|
|
||||||
|
|
||||||
// ═══ Wie Werte aus der Datenbank ankommen ════════════════════════
|
|
||||||
//
|
|
||||||
// Der Wechsel des Zugriffswegs hat hier eine Falle hinterlassen, die kein
|
|
||||||
// Typprüfer und keiner der Tests fangen konnte.
|
|
||||||
//
|
|
||||||
// Die alte API-Schicht lieferte JSON: ein `date` kam als "2026-08-03" an,
|
|
||||||
// ein `numeric` als Zahl. Genau so steht es in lib/supabase/types.ts, und
|
|
||||||
// darauf baut die gesamte Anwendung — Sortierungen mit localeCompare,
|
|
||||||
// Vergleiche wie `entry_date <= stichtag`, das Ableiten des Status.
|
|
||||||
//
|
|
||||||
// Der `pg`-Treiber macht es anders herum: aus `date` wird ein Date-Objekt,
|
|
||||||
// aus `numeric` eine Zeichenkette. Die Deklarationen blieben dabei
|
|
||||||
// unverändert gültig — sie beschreiben ja nur, was der Code *glaubt*. Der
|
|
||||||
// Fehler zeigt sich erst zur Laufzeit, und im günstigen Fall als Absturz
|
|
||||||
// („a.date.localeCompare is not a function"). Im ungünstigen Fall gar
|
|
||||||
// nicht: ein Datumsvergleich zwischen Date und Zeichenkette wirft nicht, er
|
|
||||||
// liefert bloss das falsche Ergebnis.
|
|
||||||
//
|
|
||||||
// Deshalb wird der Treiber hier auf die Form zurückgestellt, die die Typen
|
|
||||||
// beschreiben. Das ist die kleinere und ehrlichere Änderung, als 49
|
|
||||||
// Abfragestellen umzuschreiben.
|
|
||||||
//
|
|
||||||
// Nebenbei löst es ein zweites Problem: `date` ist ein Kalendertag ohne
|
|
||||||
// Zeitzone. Als Date-Objekt bekäme er eine — Mitternacht in der Zone des
|
|
||||||
// Servers —, und ein Geburtsdatum verschöbe sich beim Formatieren um einen
|
|
||||||
// Tag. Dieselbe Klasse von Fehler wie im Seed.
|
|
||||||
types.setTypeParser(1082, (v) => v); // date → "YYYY-MM-DD", unverändert
|
|
||||||
types.setTypeParser(1184, (v) => new Date(v).toISOString()); // timestamptz → ISO-8601 mit Z
|
|
||||||
types.setTypeParser(1114, (v) => new Date(v + "Z").toISOString()); // timestamp ohne Zone
|
|
||||||
types.setTypeParser(1700, (v) => Number(v)); // numeric → Zahl
|
|
||||||
|
|
||||||
// Bewusst *nicht* umgestellt: int8 (bigint). Es kommt nur aus count() und
|
|
||||||
// wird überall mit Number() gelesen; als Zahl geparst verlöre es jenseits
|
|
||||||
// von 2^53 stillschweigend an Genauigkeit.
|
|
||||||
|
|
||||||
// Die einzige Stelle im Projekt, die `pg` importieren darf.
|
|
||||||
//
|
|
||||||
// Der Grund steht in lib/db/index.ts: eine Abfrage ausserhalb von withUser()
|
|
||||||
// läuft ohne Sitzungskontext und damit — je nachdem, was die vorherige
|
|
||||||
// Anfrage auf derselben gepoolten Verbindung hinterlassen hat — im Namen
|
|
||||||
// einer fremden Person. Deshalb wird der Pool nicht exportiert, sondern nur
|
|
||||||
// die Kysely-Instanz, die ihn benutzt, und eine ESLint-Regel verbietet den
|
|
||||||
// Import von `pg` und von dieser Datei überall sonst.
|
|
||||||
|
|
||||||
// Der Pool hängt am globalen Objekt, nicht nur am Modul.
|
|
||||||
//
|
|
||||||
// Im Entwicklungsbetrieb lädt Next.js geänderte Module neu. Ein modul-lokales
|
|
||||||
// `let` wäre danach leer, der alte Pool aber weiterhin am Leben — mit seinen
|
|
||||||
// Verbindungen. Nach ein paar Bearbeitungen ist das Kontingent des Anbieters
|
|
||||||
// aufgebraucht, und die Anwendung antwortet nur noch mit „max clients
|
|
||||||
// reached". Genau so ist sie hier stehengeblieben.
|
|
||||||
//
|
|
||||||
// In der Produktion gibt es kein Neuladen; dort ist die Zeile wirkungslos.
|
|
||||||
const globalForPool = globalThis as typeof globalThis & { __alpenwerkPool?: Pool };
|
|
||||||
|
|
||||||
let instance: Pool | undefined = globalForPool.__alpenwerkPool;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Der Verbindungspool — erst beim ersten Zugriff angelegt, nicht beim Import.
|
|
||||||
*
|
|
||||||
* Der Unterschied ist nicht kosmetisch: `next build` importiert jedes Route-
|
|
||||||
* Modul, um die Seitendaten einzusammeln. Entstünde der Pool dabei, bräuchte
|
|
||||||
* schon der Bau Zugangsdaten zur Datenbank — ein Container-Abbild liesse sich
|
|
||||||
* in einer Baustrecke ohne Produktionsgeheimnisse nicht mehr erzeugen.
|
|
||||||
*/
|
|
||||||
export function getPool(): Pool {
|
|
||||||
if (instance) return instance;
|
|
||||||
|
|
||||||
const connectionString = process.env.DATABASE_URL;
|
|
||||||
if (!connectionString) {
|
|
||||||
throw new Error(
|
|
||||||
"DATABASE_URL fehlt. Erwartet wird ein PostgreSQL-Verbindungsstring — " +
|
|
||||||
"die Anwendung spricht direkt mit der Datenbank, nicht über eine API-Schicht."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
instance = new Pool({
|
|
||||||
connectionString,
|
|
||||||
// Der Standard sind 10; bei serverseitigem Rendering hängt an jeder
|
|
||||||
// Anfrage genau eine Transaktion, und mehr Verbindungen als die Datenbank
|
|
||||||
// zulässt bringen nur Wartezeit an einer anderen Stelle.
|
|
||||||
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
|
|
||||||
// Eine Anfrage, die länger braucht, ist kaputt und soll das melden statt
|
|
||||||
// eine Verbindung zu belegen.
|
|
||||||
statement_timeout: 20_000,
|
|
||||||
idle_in_transaction_session_timeout: 20_000,
|
|
||||||
connectionTimeoutMillis: 10_000,
|
|
||||||
// Verwaltete Anbieter (Azure, RDS, Supabase) verlangen TLS; lokal nicht.
|
|
||||||
ssl: process.env.DATABASE_SSL === "false" ? undefined : { rejectUnauthorized: false },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ein Fehler auf einer Leerlaufverbindung beendet sonst den Prozess.
|
|
||||||
instance.on("error", (err) => {
|
|
||||||
console.error("Unerwarteter Fehler auf einer Leerlaufverbindung:", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
globalForPool.__alpenwerkPool = instance;
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { sql, withUser, type Tx } from "./index";
|
|
||||||
import type { Database } from "@/lib/supabase/types";
|
|
||||||
|
|
||||||
// Aufruf einer Datenbankfunktion.
|
|
||||||
//
|
|
||||||
// Die Geschäftslogik liegt in PL/pgSQL — Eintritt, Versetzung, Austritt und
|
|
||||||
// die übrigen zehn Mutationen. Daran ändert der Wechsel des Zugriffswegs
|
|
||||||
// nichts: es fällt nur die API-Schicht dazwischen weg. Aufgerufen wird die
|
|
||||||
// Funktion jetzt unmittelbar, innerhalb der Transaktion, in der auch der
|
|
||||||
// Sitzungskontext gilt — ohne den würde require_hr_admin() darin abweisen.
|
|
||||||
|
|
||||||
export type MutationFn = keyof Database["public"]["Functions"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ruft `fn(payload)` innerhalb der laufenden Transaktion auf.
|
|
||||||
*
|
|
||||||
* `payload` weglassen für die Funktionen ohne Argument —
|
|
||||||
* apply_due_pending_changes() ist die einzige. Mit einem jsonb-Argument
|
|
||||||
* aufgerufen fände Postgres keine passende Signatur.
|
|
||||||
*/
|
|
||||||
export async function callFunction(tx: Tx, fn: MutationFn, payload?: Record<string, unknown>): Promise<unknown> {
|
|
||||||
// Der Funktionsname stammt aus einer geschlossenen Aufzählung, nie aus
|
|
||||||
// einer Eingabe — sonst wäre die Verkettung hier eine Einladung.
|
|
||||||
const name = sql.raw(`"${fn}"`);
|
|
||||||
const query =
|
|
||||||
payload === undefined
|
|
||||||
? sql<{ result: unknown }>`select ${name}() as result`
|
|
||||||
: sql<{ result: unknown }>`select ${name}(${sql.val(JSON.stringify(payload))}::jsonb) as result`;
|
|
||||||
const result = await query.execute(tx);
|
|
||||||
return result.rows[0]?.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ActionResult = { success: boolean; error?: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Eine Mutation im Namen der angemeldeten Person, mit der üblichen
|
|
||||||
* Fehlerbehandlung für Server Actions.
|
|
||||||
*
|
|
||||||
* Die Prüfung der Berechtigung passiert in der Funktion selbst
|
|
||||||
* (require_hr_admin) und unabhängig davon in den RLS-Policies — nicht hier.
|
|
||||||
*/
|
|
||||||
export async function runMutation(
|
|
||||||
userId: string | null,
|
|
||||||
fn: MutationFn,
|
|
||||||
payload: Record<string, unknown>
|
|
||||||
): Promise<ActionResult> {
|
|
||||||
try {
|
|
||||||
await withUser(userId, (tx) => callFunction(tx, fn, payload));
|
|
||||||
return { success: true };
|
|
||||||
} catch (err) {
|
|
||||||
// Die Meldungen der Funktionen sind für die Oberfläche geschrieben
|
|
||||||
// („Diese Planstelle ist bereits besetzt.") und werden durchgereicht.
|
|
||||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { ColumnType } from "kysely";
|
|
||||||
import type { Database } from "@/lib/supabase/types";
|
|
||||||
|
|
||||||
// Die Tabellenform für Kysely, abgeleitet aus der bestehenden
|
|
||||||
// Schemabeschreibung — nicht daneben gestellt.
|
|
||||||
//
|
|
||||||
// Zwei Beschreibungen desselben Schemas driften auseinander, und die eine
|
|
||||||
// hier ist bereits gegen die Migrationen abgesichert: `npm run types:check`
|
|
||||||
// vergleicht lib/supabase/types.ts Spalte für Spalte mit
|
|
||||||
// supabase/migrations/*.sql und schlägt in der CI fehl, wenn etwas fehlt.
|
|
||||||
// Diese Ableitung erbt diese Absicherung.
|
|
||||||
//
|
|
||||||
// Die Datei heisst noch lib/supabase/types.ts, weil sie aus der Zeit stammt,
|
|
||||||
// als PostgREST der Zugriffsweg war. Sie beschreibt reines PostgreSQL und
|
|
||||||
// wird beim Entfernen der Supabase-Pakete lediglich umbenannt.
|
|
||||||
|
|
||||||
type Tables = Database["public"]["Tables"];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Row/Insert/Update einer Tabelle in Kyselys ColumnType-Form.
|
|
||||||
*
|
|
||||||
* Kysely braucht die drei Richtungen getrennt: was beim Lesen herauskommt,
|
|
||||||
* was beim Einfügen erlaubt ist (Spalten mit Vorgabewert dürfen fehlen) und
|
|
||||||
* was beim Aktualisieren erlaubt ist.
|
|
||||||
*/
|
|
||||||
type Columns<T extends keyof Tables> = {
|
|
||||||
[K in keyof Tables[T]["Row"]]: K extends keyof Tables[T]["Insert"]
|
|
||||||
? ColumnType<
|
|
||||||
Tables[T]["Row"][K],
|
|
||||||
Tables[T]["Insert"][K],
|
|
||||||
K extends keyof Tables[T]["Update"] ? Tables[T]["Update"][K] : never
|
|
||||||
>
|
|
||||||
: // Spalten, die es nur beim Lesen gibt (von Triggern gesetzt).
|
|
||||||
ColumnType<Tables[T]["Row"][K], never, never>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DB = { [T in keyof Tables]: Columns<T> };
|
|
||||||
|
|
||||||
/** Die Tabelle, die auth.users ablöst. Noch nicht in der Alt-Beschreibung. */
|
|
||||||
export type AppUsersTable = {
|
|
||||||
id: ColumnType<string, string | undefined, never>;
|
|
||||||
external_id: string;
|
|
||||||
email: string;
|
|
||||||
full_name: ColumnType<string | null, string | null | undefined, string | null>;
|
|
||||||
created_at: ColumnType<string, string | undefined, never>;
|
|
||||||
last_seen_at: ColumnType<string | null, string | null | undefined, string | null>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Schema = DB & { app_users: AppUsersTable };
|
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { Expression, ExpressionBuilder, SqlBool } from "kysely";
|
|
||||||
import type { Schema } from "./db/schema";
|
|
||||||
import type { EmploymentStatus } from "./supabase/types";
|
import type { EmploymentStatus } from "./supabase/types";
|
||||||
|
|
||||||
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
|
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
|
||||||
@@ -21,59 +19,58 @@ import type { EmploymentStatus } from "./supabase/types";
|
|||||||
// tests/integration/employee-status-filter.test.ts asserts the two agree
|
// tests/integration/employee-status-filter.test.ts asserts the two agree
|
||||||
// against a real database, which is the only place that can prove it.
|
// against a real database, which is the only place that can prove it.
|
||||||
|
|
||||||
type Eb = ExpressionBuilder<Schema, "employees">;
|
type Filterable = {
|
||||||
|
gt: (column: string, value: string) => Filterable;
|
||||||
|
lte: (column: string, value: string) => Filterable;
|
||||||
|
gte: (column: string, value: string) => Filterable;
|
||||||
|
or: (filters: string) => Filterable;
|
||||||
|
is: (column: string, value: null) => Filterable;
|
||||||
|
not: (column: string, operator: string, value: null) => Filterable;
|
||||||
|
};
|
||||||
|
|
||||||
/** True once the person has started and has not left yet. */
|
/** True once the person has started and has not left yet. */
|
||||||
function employed(eb: Eb, asOf: string): Expression<SqlBool> {
|
function employed<Q extends Filterable>(query: Q, asOf: string): Q {
|
||||||
return eb.and([eb("entry_date", "<=", asOf), eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)])]);
|
return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Bedingung für die Menge, deren *abgeleiteter* Status am Stichtag einer
|
* Narrows a PostgREST query to the employees whose *derived* status on
|
||||||
* der genannten ist — oder null, wenn nicht eingeschränkt werden soll.
|
* `asOf` is one of `statuses`. Only the combinations the UI offers are
|
||||||
*
|
* supported; anything else is left unfiltered rather than silently applying
|
||||||
* Nur die Kombinationen, die die Oberfläche anbietet, sind abgedeckt. Für
|
* a wrong one.
|
||||||
* alles andere kommt null zurück: lieber nicht filtern als falsch filtern.
|
|
||||||
*/
|
*/
|
||||||
export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf: string): Expression<SqlBool> | null {
|
export function applyDerivedStatusFilter<Q extends Filterable>(query: Q, statuses: EmploymentStatus[], asOf: string): Q {
|
||||||
const wanted = new Set(statuses);
|
const wanted = new Set(statuses);
|
||||||
if (wanted.size === 0) return null;
|
if (wanted.size === 0) return query;
|
||||||
|
|
||||||
// A single non-employed status is a straight date comparison.
|
// A single non-employed status is a straight date comparison.
|
||||||
if (wanted.size === 1 && wanted.has("Geplant")) return eb("entry_date", ">", asOf);
|
if (wanted.size === 1 && wanted.has("Geplant")) return query.gt("entry_date", asOf) as Q;
|
||||||
if (wanted.size === 1 && wanted.has("Ausgetreten")) {
|
if (wanted.size === 1 && wanted.has("Ausgetreten")) return query.not("exit_date", "is", null).lte("exit_date", asOf) as Q;
|
||||||
return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wantsAktiv = wanted.has("Aktiv");
|
const wantsAktiv = wanted.has("Aktiv");
|
||||||
const wantsKarenz = wanted.has("Karenz");
|
const wantsKarenz = wanted.has("Karenz");
|
||||||
|
|
||||||
|
if (wantsAktiv && wantsKarenz && wanted.size === 2) {
|
||||||
// Everyone employed today, whether or not they are on leave.
|
// Everyone employed today, whether or not they are on leave.
|
||||||
if (wantsAktiv && wantsKarenz && wanted.size === 2) return employed(eb, asOf);
|
return employed(query, asOf);
|
||||||
|
}
|
||||||
|
|
||||||
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
|
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
|
||||||
return eb.and([
|
return employed(query, asOf)
|
||||||
employed(eb, asOf),
|
.not("karenz_start_date", "is", null)
|
||||||
eb("karenz_start_date", "is not", null),
|
.lte("karenz_start_date", asOf)
|
||||||
eb("karenz_start_date", "<=", asOf),
|
.or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q;
|
||||||
eb.or([eb("karenz_return_date", "is", null), eb("karenz_return_date", ">", asOf)]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
|
if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
|
||||||
// Employed but *not* inside a karenz window: either no start date, a
|
// Employed but *not* inside a karenz window: either no start date, a
|
||||||
// start still ahead, or a return that has already happened.
|
// start still ahead, or a return that has already happened.
|
||||||
return eb.and([
|
return employed(query, asOf).or(
|
||||||
employed(eb, asOf),
|
`karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}`
|
||||||
eb.or([
|
) as Q;
|
||||||
eb("karenz_start_date", "is", null),
|
|
||||||
eb("karenz_start_date", ">", asOf),
|
|
||||||
eb("karenz_return_date", "<=", asOf),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mixed selections spanning employed and non-employed states have no UI
|
// Mixed selections spanning employed and non-employed states have no UI
|
||||||
// path today; filtering on a guess would be worse than not filtering.
|
// path today; filtering on a guess would be worse than not filtering.
|
||||||
return null;
|
return query;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,335 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { sql, type Tx } from "@/lib/db";
|
|
||||||
import { todayIso } from "@/lib/format";
|
|
||||||
import { deriveStatusAsOf } from "@/lib/reports";
|
|
||||||
import { normalizeSvnr } from "@/lib/svnr";
|
|
||||||
import type { Bestand, Datensatz, Zeile } from "./validate";
|
|
||||||
|
|
||||||
// Schreiben einer geprüften Datei.
|
|
||||||
//
|
|
||||||
// Der Aufruf steckt in **einer** Transaktion (siehe die Route). Das ist keine
|
|
||||||
// Vorsicht, sondern die Bedingung: eine halb geladene Organisation — Bereiche
|
|
||||||
// ohne Abteilungen, Planstellen ohne Personen — ist schlimmer als gar keine,
|
|
||||||
// weil sie nach Daten aussieht. Bricht irgendetwas ab, war nichts.
|
|
||||||
//
|
|
||||||
// Angelegt wird nur; aktualisiert wird nie. Was es schon gibt, hat die
|
|
||||||
// Prüfung vorher abgewiesen. Damit kann ein Tippfehler in einer Datei keine
|
|
||||||
// bestehenden Personaldaten überschreiben.
|
|
||||||
|
|
||||||
/** Wie viele Zeilen je INSERT. Postgres verträgt 65535 Parameter je Anweisung. */
|
|
||||||
const PORTION = 200;
|
|
||||||
|
|
||||||
export type Ladebericht = Record<string, number>;
|
|
||||||
|
|
||||||
const txt = (v: unknown): string | null => (typeof v === "string" && v !== "" ? v : null);
|
|
||||||
const zahl = (v: unknown): number | null => (typeof v === "number" ? v : null);
|
|
||||||
const bool = (v: unknown, ersatz: boolean): boolean => (typeof v === "boolean" ? v : ersatz);
|
|
||||||
const liste = (v: unknown): string[] | null => (Array.isArray(v) ? (v as string[]) : null);
|
|
||||||
|
|
||||||
async function einfuegen<T>(tx: Tx, tabelle: string, zeilen: T[]): Promise<void> {
|
|
||||||
for (let i = 0; i < zeilen.length; i += PORTION) {
|
|
||||||
await tx
|
|
||||||
// Die Tabellennamen stammen aus einer geschlossenen Aufzählung in
|
|
||||||
// diesem Modul, nie aus der Datei.
|
|
||||||
.insertInto(tabelle as never)
|
|
||||||
.values(zeilen.slice(i, i + PORTION) as never)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Was bereits im System steht.
|
|
||||||
*
|
|
||||||
* Läuft in derselben Transaktion wie das Schreiben. Zwischen Prüfung und
|
|
||||||
* Schreiben könnte sonst jemand dieselbe Planstelle besetzen, und der Import
|
|
||||||
* liefe in den Teilindex statt in eine verständliche Meldung.
|
|
||||||
*/
|
|
||||||
export async function bestandLaden(tx: Tx): Promise<Bestand> {
|
|
||||||
const heute = todayIso();
|
|
||||||
|
|
||||||
const [standorte, einheiten, jobs, stellen, besetzungen, personen] = await Promise.all([
|
|
||||||
tx.selectFrom("locations").select(["id", "name"]).execute(),
|
|
||||||
tx.selectFrom("org_units").select(["id", "org_number"]).execute(),
|
|
||||||
tx.selectFrom("jobs").select(["id", "code"]).execute(),
|
|
||||||
tx.selectFrom("om_positions").select(["id", "position_number"]).execute(),
|
|
||||||
tx
|
|
||||||
.selectFrom("position_assignments")
|
|
||||||
.select(["position_id"])
|
|
||||||
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">=", heute)]))
|
|
||||||
.execute(),
|
|
||||||
tx.selectFrom("employees").select(["id", "personnel_number", "email", "sv_nummer"]).execute(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const besetzt = new Set(besetzungen.map((b) => b.position_id));
|
|
||||||
|
|
||||||
return {
|
|
||||||
standorte: new Map(standorte.map((l) => [l.name, l.id])),
|
|
||||||
orgNummern: new Map(einheiten.map((o) => [o.org_number, o.id])),
|
|
||||||
jobCodes: new Map(jobs.map((j) => [j.code, j.id])),
|
|
||||||
planstellen: new Map(stellen.map((p) => [p.position_number, { id: p.id, besetzt: besetzt.has(p.id) }])),
|
|
||||||
personalnummern: new Map(personen.map((e) => [e.personnel_number, e.id])),
|
|
||||||
emails: new Set(personen.map((e) => e.email.toLowerCase())),
|
|
||||||
svNummern: new Set(personen.filter((e) => e.sv_nummer).map((e) => normalizeSvnr(e.sv_nummer!))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ordnet Einheiten so, dass jede nach ihrer übergeordneten kommt.
|
|
||||||
*
|
|
||||||
* Der Fremdschlüssel auf parent_id wird je Zeile geprüft, und eine Datei
|
|
||||||
* darf ihre Zeilen in beliebiger Reihenfolge führen — eine Mappe, die nach
|
|
||||||
* Bezeichnung sortiert ist, hätte sonst Pech.
|
|
||||||
*/
|
|
||||||
function elternZuerst(zeilen: Zeile[], bekannt: Set<string>): { sortiert: Zeile[]; ungeloest: Zeile[] } {
|
|
||||||
const offen = [...zeilen];
|
|
||||||
const sortiert: Zeile[] = [];
|
|
||||||
const erledigt = new Set(bekannt);
|
|
||||||
|
|
||||||
let fortschritt = true;
|
|
||||||
while (offen.length > 0 && fortschritt) {
|
|
||||||
fortschritt = false;
|
|
||||||
for (let i = offen.length - 1; i >= 0; i--) {
|
|
||||||
const eltern = txt(offen[i].werte.parent_org_number);
|
|
||||||
if (!eltern || erledigt.has(eltern)) {
|
|
||||||
const nummer = txt(offen[i].werte.org_number);
|
|
||||||
if (nummer) erledigt.add(nummer);
|
|
||||||
sortiert.push(offen[i]);
|
|
||||||
offen.splice(i, 1);
|
|
||||||
fortschritt = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Was übrig bleibt, hängt in einem Kreis — die Prüfung fängt den einfachen
|
|
||||||
// Fall (sich selbst übergeordnet), längere Ketten fallen hier auf.
|
|
||||||
return { sortiert, ungeloest: offen };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function laden(
|
|
||||||
tx: Tx,
|
|
||||||
datensatz: Datensatz,
|
|
||||||
bestand: Bestand,
|
|
||||||
akteur: { userId: string; name: string }
|
|
||||||
): Promise<Ladebericht> {
|
|
||||||
const hole = (name: string) => datensatz[name] ?? [];
|
|
||||||
const bericht: Ladebericht = {};
|
|
||||||
const heute = todayIso();
|
|
||||||
|
|
||||||
// ── Standorte ─────────────────────────────────────────────────
|
|
||||||
const standortId = new Map(bestand.standorte);
|
|
||||||
const neueStandorte = hole("Standorte").map((z) => ({
|
|
||||||
name: txt(z.werte.name)!,
|
|
||||||
country: txt(z.werte.country)!,
|
|
||||||
}));
|
|
||||||
if (neueStandorte.length) {
|
|
||||||
const zurueck = await tx.insertInto("locations").values(neueStandorte).returning(["id", "name"]).execute();
|
|
||||||
for (const r of zurueck) standortId.set(r.name, r.id);
|
|
||||||
}
|
|
||||||
bericht.Standorte = neueStandorte.length;
|
|
||||||
|
|
||||||
// ── Organisation ──────────────────────────────────────────────
|
|
||||||
const orgId = new Map(bestand.orgNummern);
|
|
||||||
const { sortiert, ungeloest } = elternZuerst(hole("Organisation"), new Set(orgId.keys()));
|
|
||||||
if (ungeloest.length) {
|
|
||||||
throw new Error(
|
|
||||||
`Die übergeordneten Einheiten von ${ungeloest.length} Zeile(n) lassen sich nicht auflösen — vermutlich ein Kreis in der Spalte „Übergeordnet“.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const z of sortiert) {
|
|
||||||
const eltern = txt(z.werte.parent_org_number);
|
|
||||||
const r = await tx
|
|
||||||
.insertInto("org_units")
|
|
||||||
.values({
|
|
||||||
org_number: txt(z.werte.org_number)!,
|
|
||||||
name: txt(z.werte.name)!,
|
|
||||||
unit_type: txt(z.werte.unit_type) as never,
|
|
||||||
parent_id: eltern ? orgId.get(eltern)! : null,
|
|
||||||
valid_from: txt(z.werte.valid_from) ?? heute,
|
|
||||||
valid_to: txt(z.werte.valid_to),
|
|
||||||
})
|
|
||||||
.returning(["id", "org_number"])
|
|
||||||
.execute();
|
|
||||||
orgId.set(r[0].org_number, r[0].id);
|
|
||||||
}
|
|
||||||
bericht.Organisation = sortiert.length;
|
|
||||||
|
|
||||||
// ── Jobkatalog ────────────────────────────────────────────────
|
|
||||||
const jobId = new Map(bestand.jobCodes);
|
|
||||||
const neueJobs = hole("Jobkatalog").map((z) => ({ code: txt(z.werte.code)!, title: txt(z.werte.title)! }));
|
|
||||||
if (neueJobs.length) {
|
|
||||||
const zurueck = await tx.insertInto("jobs").values(neueJobs).returning(["id", "code"]).execute();
|
|
||||||
for (const r of zurueck) jobId.set(r.code, r.id);
|
|
||||||
}
|
|
||||||
bericht.Jobkatalog = neueJobs.length;
|
|
||||||
|
|
||||||
// ── Planstellen ───────────────────────────────────────────────
|
|
||||||
const stellenId = new Map([...bestand.planstellen].map(([nr, p]) => [nr, p.id]));
|
|
||||||
const neueStellen = hole("Planstellen").map((z) => ({
|
|
||||||
position_number: txt(z.werte.position_number)!,
|
|
||||||
org_unit_id: orgId.get(txt(z.werte.org_number)!)!,
|
|
||||||
job_id: jobId.get(txt(z.werte.job_code)!)!,
|
|
||||||
is_chief: bool(z.werte.is_chief, false),
|
|
||||||
valid_from: txt(z.werte.valid_from) ?? heute,
|
|
||||||
valid_to: txt(z.werte.valid_to),
|
|
||||||
}));
|
|
||||||
if (neueStellen.length) {
|
|
||||||
const zurueck = await tx
|
|
||||||
.insertInto("om_positions")
|
|
||||||
.values(neueStellen)
|
|
||||||
.returning(["id", "position_number"])
|
|
||||||
.execute();
|
|
||||||
for (const r of zurueck) stellenId.set(r.position_number, r.id);
|
|
||||||
}
|
|
||||||
bericht.Planstellen = neueStellen.length;
|
|
||||||
|
|
||||||
// ── Personen ──────────────────────────────────────────────────
|
|
||||||
const personId = new Map(bestand.personalnummern);
|
|
||||||
const personenZeilen = hole("Personen");
|
|
||||||
const besetzungen: { position_id: string; employee_id: string; valid_from: string; valid_to: string | null }[] = [];
|
|
||||||
|
|
||||||
for (const z of personenZeilen) {
|
|
||||||
const w = z.werte;
|
|
||||||
const eintritt = txt(w.entry_date)!;
|
|
||||||
const austritt = txt(w.exit_date);
|
|
||||||
const karenzVon = txt(w.karenz_start_date);
|
|
||||||
const karenzBis = txt(w.karenz_return_date);
|
|
||||||
|
|
||||||
const werte = {
|
|
||||||
personnel_number: zahl(w.personnel_number)!,
|
|
||||||
first_name: txt(w.first_name)!,
|
|
||||||
last_name: txt(w.last_name)!,
|
|
||||||
gender: txt(w.gender) as never,
|
|
||||||
birth_date: txt(w.birth_date)!,
|
|
||||||
sv_nummer: txt(w.sv_nummer) ? normalizeSvnr(txt(w.sv_nummer)!) : null,
|
|
||||||
nationality: txt(w.nationality) ?? "Österreich",
|
|
||||||
address: txt(w.address),
|
|
||||||
postal_code: txt(w.postal_code),
|
|
||||||
city: txt(w.city),
|
|
||||||
address_country: txt(w.address_country),
|
|
||||||
email: txt(w.email)!,
|
|
||||||
phone: txt(w.phone),
|
|
||||||
title_prefix: liste(w.title_prefix) ?? [],
|
|
||||||
title_suffix: liste(w.title_suffix) ?? [],
|
|
||||||
job_title: txt(w.job_title)!,
|
|
||||||
location_id: standortId.get(txt(w.location)!)!,
|
|
||||||
employment_type: (txt(w.employment_type) ?? "Vollzeit") as never,
|
|
||||||
weekly_hours: zahl(w.weekly_hours) ?? 38.5,
|
|
||||||
// Die Prüfung hat jeden Eintrag gegen die Wochentage abgeglichen und
|
|
||||||
// auf die Schreibweise der Datenbank gebracht; hier steht deshalb
|
|
||||||
// sicher nur Mo…So.
|
|
||||||
work_days: (liste(w.work_days) ?? ["Mo", "Di", "Mi", "Do", "Fr"]) as never,
|
|
||||||
contract_type: (txt(w.contract_type) ?? "unbefristet") as never,
|
|
||||||
contract_end_date: txt(w.contract_end_date),
|
|
||||||
paygrade: (txt(w.paygrade) ?? "B") as never,
|
|
||||||
collective_agreement: (txt(w.collective_agreement) ?? "Handel") as never,
|
|
||||||
worker_type: (txt(w.worker_type) ?? "Angestellte:r") as never,
|
|
||||||
monthly_salary_gross: zahl(w.monthly_salary_gross),
|
|
||||||
source: (txt(w.source) ?? "Extern") as never,
|
|
||||||
is_betriebsrat: bool(w.is_betriebsrat, false),
|
|
||||||
has_dienstwagen: bool(w.has_dienstwagen, false),
|
|
||||||
is_laterale_fuehrung: bool(w.is_laterale_fuehrung, false),
|
|
||||||
is_c_level: bool(w.is_c_level, false),
|
|
||||||
entry_date: eintritt,
|
|
||||||
exit_date: austritt,
|
|
||||||
exit_reason: txt(w.exit_reason),
|
|
||||||
karenz_start_date: karenzVon,
|
|
||||||
karenz_return_date: karenzBis,
|
|
||||||
absence_type: txt(w.absence_type),
|
|
||||||
// Der Status wird **abgeleitet**, nicht importiert. Stünde er in der
|
|
||||||
// Datei, könnte er den Daten widersprechen — jemand mit Austritt und
|
|
||||||
// Status „Aktiv“ —, und die Anwendung leitet ihn ohnehin überall aus
|
|
||||||
// denselben Datumsangaben ab.
|
|
||||||
status: deriveStatusAsOf(
|
|
||||||
{ entry_date: eintritt, exit_date: austritt, karenz_start_date: karenzVon, karenz_return_date: karenzBis },
|
|
||||||
heute
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
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);
|
|
||||||
|
|
||||||
personId.set(r.rows[0].personnel_number, r.rows[0].id);
|
|
||||||
|
|
||||||
const stelle = txt(w.position_number);
|
|
||||||
if (stelle) {
|
|
||||||
besetzungen.push({
|
|
||||||
position_id: stellenId.get(stelle)!,
|
|
||||||
employee_id: r.rows[0].id,
|
|
||||||
valid_from: eintritt,
|
|
||||||
// Beim Austritt endet die Besetzung — sonst gälte die Planstelle als
|
|
||||||
// belegt und liesse sich nicht nachbesetzen.
|
|
||||||
valid_to: austritt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bericht.Personen = personenZeilen.length;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
await einfuegen(tx, "position_assignments", besetzungen);
|
|
||||||
|
|
||||||
// ── Historie und Angehörige ───────────────────────────────────
|
|
||||||
const historie = hole("Historie").map((z) => ({
|
|
||||||
employee_id: personId.get(zahl(z.werte.personnel_number)!)!,
|
|
||||||
event_date: txt(z.werte.event_date)!,
|
|
||||||
event_type: txt(z.werte.event_type)! as never,
|
|
||||||
description: txt(z.werte.description)!,
|
|
||||||
}));
|
|
||||||
await einfuegen(tx, "employee_history", historie);
|
|
||||||
bericht.Historie = historie.length;
|
|
||||||
|
|
||||||
const angehoerige = hole("Angehörige").map((z) => ({
|
|
||||||
employee_id: personId.get(zahl(z.werte.personnel_number)!)!,
|
|
||||||
first_name: txt(z.werte.first_name)!,
|
|
||||||
last_name: txt(z.werte.last_name)!,
|
|
||||||
relationship: txt(z.werte.relationship)!,
|
|
||||||
birth_date: txt(z.werte.birth_date)!,
|
|
||||||
sv_nummer: txt(z.werte.sv_nummer) ? normalizeSvnr(txt(z.werte.sv_nummer)!) : null,
|
|
||||||
}));
|
|
||||||
await einfuegen(tx, "employee_dependents", angehoerige);
|
|
||||||
bericht.Angehörige = angehoerige.length;
|
|
||||||
|
|
||||||
// ── Protokoll ─────────────────────────────────────────────────
|
|
||||||
// Ein Eintrag für den ganzen Vorgang, nicht einer je Zeile: 800 Zeilen
|
|
||||||
// würden das Protokoll unlesbar machen, und der Vorgang ist ohnehin
|
|
||||||
// untrennbar — er ist eine Transaktion.
|
|
||||||
const zusammenfassung = Object.entries(bericht)
|
|
||||||
.filter(([, n]) => n > 0)
|
|
||||||
.map(([blatt, n]) => `${n} ${blatt}`)
|
|
||||||
.join(", ");
|
|
||||||
await tx
|
|
||||||
.insertInto("audit_log")
|
|
||||||
.values({
|
|
||||||
actor_user_id: akteur.userId,
|
|
||||||
actor_name: akteur.name,
|
|
||||||
action: "Import",
|
|
||||||
target_label: "Massenimport",
|
|
||||||
details: zusammenfassung || "nichts angelegt",
|
|
||||||
})
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
return bericht;
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
// Einlesen einer Importdatei — XLSX oder CSV.
|
|
||||||
//
|
|
||||||
// Zwei Dinge macht diese Datei und sonst nichts: aus Bytes werden benannte
|
|
||||||
// Blätter mit Zeilen, und aus Zellen werden verlässliche Rohwerte. Was die
|
|
||||||
// Werte *bedeuten* dürfen, steht in schema.ts; ob sie stimmen, entscheidet
|
|
||||||
// validate.ts. Diese Trennung ist der Grund, warum sich das Format testen
|
|
||||||
// lässt, ohne eine Datenbank oder eine Tabellenkalkulation zu brauchen.
|
|
||||||
|
|
||||||
import ExcelJS from "exceljs";
|
|
||||||
|
|
||||||
/** Eine Zeile, mit der Nummer aus der Datei — ohne die ist ein Fehler nutzlos. */
|
|
||||||
export type ImportRow = {
|
|
||||||
/** Zeilennummer wie in Excel angezeigt, Kopfzeile ist 1. */
|
|
||||||
zeile: number;
|
|
||||||
werte: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ImportSheet = {
|
|
||||||
name: string;
|
|
||||||
spalten: string[];
|
|
||||||
zeilen: ImportRow[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ParseResult = {
|
|
||||||
blaetter: ImportSheet[];
|
|
||||||
/** Probleme beim Lesen selbst — kaputte Datei, leeres Blatt, doppelte Spalte. */
|
|
||||||
fehler: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trennzeichen einer CSV-Datei bestimmen.
|
|
||||||
*
|
|
||||||
* Deutschsprachiges Excel schreibt Semikolon, weil das Komma das
|
|
||||||
* Dezimaltrennzeichen ist. Eine feste Annahme auf `,` liest solche Dateien
|
|
||||||
* als eine einzige Spalte ein — und das sieht dann aus wie „die Datei hat
|
|
||||||
* keine der erwarteten Spalten", was in die Irre führt.
|
|
||||||
*
|
|
||||||
* Gezählt wird nur in der Kopfzeile, und nur ausserhalb von Anführungszeichen.
|
|
||||||
*/
|
|
||||||
export function trennzeichenErkennen(kopfzeile: string): string {
|
|
||||||
const kandidaten = [";", ",", "\t", "|"];
|
|
||||||
let bestes = ";";
|
|
||||||
let meiste = -1;
|
|
||||||
for (const k of kandidaten) {
|
|
||||||
let anzahl = 0;
|
|
||||||
let inAnfuehrung = false;
|
|
||||||
for (let i = 0; i < kopfzeile.length; i++) {
|
|
||||||
const c = kopfzeile[i];
|
|
||||||
if (c === '"') inAnfuehrung = !inAnfuehrung;
|
|
||||||
else if (c === k && !inAnfuehrung) anzahl++;
|
|
||||||
}
|
|
||||||
if (anzahl > meiste) {
|
|
||||||
meiste = anzahl;
|
|
||||||
bestes = k;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bestes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CSV nach RFC 4180, mit den Abweichungen, die in der Praxis vorkommen:
|
|
||||||
* Zeilenumbrüche innerhalb von Anführungszeichen, verdoppelte
|
|
||||||
* Anführungszeichen als Escape, CRLF wie LF.
|
|
||||||
*
|
|
||||||
* Eine eigene Zerlegung statt einer Bibliothek, weil genau diese drei Fälle
|
|
||||||
* das sind, woran naive Zerlegungen scheitern — und weil ein Feld mit einem
|
|
||||||
* Semikolon darin (eine Adresse, eine Beschreibung) sonst still die Spalten
|
|
||||||
* verschiebt und die Zeile plausibel falsch importiert wird.
|
|
||||||
*/
|
|
||||||
export function csvZerlegen(text: string, trennzeichen?: string): string[][] {
|
|
||||||
const ohneBom = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
||||||
const trenner = trennzeichen ?? trennzeichenErkennen(ohneBom.split(/\r?\n/, 1)[0] ?? "");
|
|
||||||
|
|
||||||
const zeilen: string[][] = [];
|
|
||||||
let feld = "";
|
|
||||||
let zeile: string[] = [];
|
|
||||||
let inAnfuehrung = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < ohneBom.length; i++) {
|
|
||||||
const c = ohneBom[i];
|
|
||||||
|
|
||||||
if (inAnfuehrung) {
|
|
||||||
if (c === '"') {
|
|
||||||
if (ohneBom[i + 1] === '"') {
|
|
||||||
feld += '"';
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
inAnfuehrung = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
feld += c;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (c === '"') {
|
|
||||||
inAnfuehrung = true;
|
|
||||||
} else if (c === trenner) {
|
|
||||||
zeile.push(feld);
|
|
||||||
feld = "";
|
|
||||||
} else if (c === "\n") {
|
|
||||||
zeile.push(feld);
|
|
||||||
zeilen.push(zeile);
|
|
||||||
zeile = [];
|
|
||||||
feld = "";
|
|
||||||
} else if (c !== "\r") {
|
|
||||||
feld += c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Letzte Zeile ohne abschliessenden Umbruch.
|
|
||||||
if (feld !== "" || zeile.length > 0) {
|
|
||||||
zeile.push(feld);
|
|
||||||
zeilen.push(zeile);
|
|
||||||
}
|
|
||||||
return zeilen;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Leerzeichen weg, doppelte innen zusammenziehen — Kopfzeilen sind selten sauber. */
|
|
||||||
function spaltenName(roh: unknown): string {
|
|
||||||
return String(roh ?? "")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zellwert als Zeichenkette.
|
|
||||||
*
|
|
||||||
* Datumswerte werden hier **nicht** interpretiert, sondern als ISO-Tag
|
|
||||||
* ausgegeben, wenn Excel sie bereits als Datum führt. Der Rest bleibt Text
|
|
||||||
* und wird erst in schema.ts gedeutet — dort weiss man, ob eine Spalte ein
|
|
||||||
* Datum sein soll, und kann einen Fehler melden statt zu raten.
|
|
||||||
*/
|
|
||||||
function zellText(wert: ExcelJS.CellValue): string {
|
|
||||||
if (wert === null || wert === undefined) return "";
|
|
||||||
if (wert instanceof Date) {
|
|
||||||
// Excel führt Datumswerte ohne Zeitzone; toISOString() würde sie über UTC
|
|
||||||
// schieben und in Österreich einen Tag zu früh ausgeben.
|
|
||||||
const m = String(wert.getUTCMonth() + 1).padStart(2, "0");
|
|
||||||
const t = String(wert.getUTCDate()).padStart(2, "0");
|
|
||||||
return `${wert.getUTCFullYear()}-${m}-${t}`;
|
|
||||||
}
|
|
||||||
if (typeof wert === "object") {
|
|
||||||
const o = wert as { text?: unknown; result?: unknown; richText?: { text: string }[]; error?: unknown };
|
|
||||||
if (Array.isArray(o.richText)) return o.richText.map((t) => t.text).join("");
|
|
||||||
// Formelzellen: das Ergebnis zählt, nicht die Formel. Eine Fehlerzelle
|
|
||||||
// (#NV, #WERT!) wird als Text durchgereicht und fällt in der Prüfung auf.
|
|
||||||
if (o.error !== undefined) return String(o.error);
|
|
||||||
if (o.result !== undefined) return zellText(o.result as ExcelJS.CellValue);
|
|
||||||
if (o.text !== undefined) return String(o.text);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return String(wert);
|
|
||||||
}
|
|
||||||
|
|
||||||
function zeilenAusMatrix(name: string, matrix: string[][]): { blatt: ImportSheet; fehler: string[] } {
|
|
||||||
const fehler: string[] = [];
|
|
||||||
const kopf = (matrix[0] ?? []).map(spaltenName);
|
|
||||||
|
|
||||||
// Doppelte Spaltennamen: die zweite überschriebe die erste stillschweigend.
|
|
||||||
const gesehen = new Set<string>();
|
|
||||||
for (const s of kopf) {
|
|
||||||
if (!s) continue;
|
|
||||||
if (gesehen.has(s)) fehler.push(`Blatt „${name}“: Spalte „${s}“ kommt mehrfach vor.`);
|
|
||||||
gesehen.add(s);
|
|
||||||
}
|
|
||||||
|
|
||||||
const zeilen: ImportRow[] = [];
|
|
||||||
for (let i = 1; i < matrix.length; i++) {
|
|
||||||
const roh = matrix[i];
|
|
||||||
const werte: Record<string, string> = {};
|
|
||||||
let leer = true;
|
|
||||||
for (let j = 0; j < kopf.length; j++) {
|
|
||||||
const spalte = kopf[j];
|
|
||||||
if (!spalte) continue;
|
|
||||||
const wert = (roh[j] ?? "").trim();
|
|
||||||
werte[spalte] = wert;
|
|
||||||
if (wert !== "") leer = false;
|
|
||||||
}
|
|
||||||
// Leerzeilen kommen in gepflegten Dateien ständig vor (Abstand, gelöschte
|
|
||||||
// Einträge) und sind keine Fehler.
|
|
||||||
if (!leer) zeilen.push({ zeile: i + 1, werte });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { blatt: { name, spalten: kopf.filter(Boolean), zeilen }, fehler };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Liest eine XLSX-Mappe; jedes Arbeitsblatt wird ein Blatt. */
|
|
||||||
export async function xlsxLesen(daten: ArrayBuffer): Promise<ParseResult> {
|
|
||||||
const mappe = new ExcelJS.Workbook();
|
|
||||||
try {
|
|
||||||
await mappe.xlsx.load(daten);
|
|
||||||
} catch {
|
|
||||||
return { blaetter: [], fehler: ["Die Datei liess sich nicht als Excel-Mappe lesen."] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const blaetter: ImportSheet[] = [];
|
|
||||||
const fehler: string[] = [];
|
|
||||||
|
|
||||||
mappe.eachSheet((arbeitsblatt) => {
|
|
||||||
const matrix: string[][] = [];
|
|
||||||
arbeitsblatt.eachRow({ includeEmpty: true }, (zeile) => {
|
|
||||||
const werte: string[] = [];
|
|
||||||
// `values` ist 1-basiert und hat an Position 0 eine Lücke.
|
|
||||||
const roh = zeile.values as ExcelJS.CellValue[];
|
|
||||||
for (let i = 1; i < roh.length; i++) werte.push(zellText(roh[i]));
|
|
||||||
matrix.push(werte);
|
|
||||||
});
|
|
||||||
if (matrix.length === 0) return;
|
|
||||||
const { blatt, fehler: f } = zeilenAusMatrix(arbeitsblatt.name.trim(), matrix);
|
|
||||||
blaetter.push(blatt);
|
|
||||||
fehler.push(...f);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { blaetter, fehler };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liest eine CSV-Datei als *ein* Blatt.
|
|
||||||
*
|
|
||||||
* Den Blattnamen liefert der Dateiname, weil eine CSV keinen kennt: aus
|
|
||||||
* „Personen.csv" wird das Blatt „Personen". Damit lassen sich mehrere CSVs
|
|
||||||
* genau wie die Blätter einer Mappe zusammensetzen.
|
|
||||||
*/
|
|
||||||
export function csvLesen(text: string, dateiname: string): ParseResult {
|
|
||||||
const matrix = csvZerlegen(text);
|
|
||||||
if (matrix.length === 0) return { blaetter: [], fehler: [`„${dateiname}“ ist leer.`] };
|
|
||||||
const name = dateiname.replace(/\.[^.]+$/, "").trim();
|
|
||||||
const { blatt, fehler } = zeilenAusMatrix(name, matrix);
|
|
||||||
return { blaetter: [blatt], fehler };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Erkennt am Dateinamen, welcher Leser zuständig ist. */
|
|
||||||
export async function dateiLesen(dateiname: string, daten: ArrayBuffer): Promise<ParseResult> {
|
|
||||||
const endung = dateiname.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] ?? "";
|
|
||||||
if (endung === "xlsx" || endung === "xlsm") return xlsxLesen(daten);
|
|
||||||
if (endung === "csv" || endung === "txt") return csvLesen(new TextDecoder("utf-8").decode(daten), dateiname);
|
|
||||||
return {
|
|
||||||
blaetter: [],
|
|
||||||
fehler: [`„${dateiname}“: unbekannte Dateiendung. Erwartet werden .xlsx oder .csv.`],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,449 +0,0 @@
|
|||||||
import { ABSENCE_TYPES } from "@/lib/absence";
|
|
||||||
import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles";
|
|
||||||
|
|
||||||
// Was in einer Importdatei stehen darf.
|
|
||||||
//
|
|
||||||
// Diese Datei ist die einzige Quelle für drei Dinge, die sonst
|
|
||||||
// auseinanderlaufen: die Prüfung beim Import, die Vorlage zum Herunterladen
|
|
||||||
// und die Hilfetexte in der Oberfläche. Eine Spalte, die hier nicht steht,
|
|
||||||
// gibt es nirgends — und eine, die hier dazukommt, taucht überall auf.
|
|
||||||
//
|
|
||||||
// Die Blattnamen sind zugleich die Dateinamen für den CSV-Weg: „Personen.csv"
|
|
||||||
// wird als Blatt „Personen" gelesen.
|
|
||||||
|
|
||||||
export type FeldArt =
|
|
||||||
| { art: "text" }
|
|
||||||
| { art: "datum" }
|
|
||||||
| { art: "zahl" }
|
|
||||||
| { art: "ganzzahl" }
|
|
||||||
| { art: "janein" }
|
|
||||||
| { art: "liste"; werte?: readonly string[] }
|
|
||||||
| { art: "auswahl"; werte: readonly string[] };
|
|
||||||
|
|
||||||
export type Spalte = {
|
|
||||||
/** Überschrift in der Datei. */
|
|
||||||
name: string;
|
|
||||||
/** Feld im Datensatz, den der Lader schreibt. */
|
|
||||||
ziel: string;
|
|
||||||
pflicht: boolean;
|
|
||||||
typ: FeldArt;
|
|
||||||
hinweis: string;
|
|
||||||
beispiel: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BlattSchema = {
|
|
||||||
name: string;
|
|
||||||
zweck: string;
|
|
||||||
/**
|
|
||||||
* Spalte, an der eine Zeile erkennbar ist. Doppelte Werte darin sind ein
|
|
||||||
* Fehler — sonst überschriebe die zweite Zeile die erste, und welche
|
|
||||||
* gewinnt, hinge an der Reihenfolge in der Datei.
|
|
||||||
*/
|
|
||||||
schluessel: string | null;
|
|
||||||
spalten: Spalte[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const WOCHENTAGE = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"] as const;
|
|
||||||
const VERHAELTNIS = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"] as const;
|
|
||||||
const EREIGNIS = [
|
|
||||||
"Eintritt",
|
|
||||||
"Beförderung",
|
|
||||||
"Versetzung",
|
|
||||||
"Karenz",
|
|
||||||
"Vertragsänderung",
|
|
||||||
"Stammdatenänderung",
|
|
||||||
"Austritt",
|
|
||||||
"Wiedereintritt",
|
|
||||||
"Reorganisation",
|
|
||||||
"Gehaltsanpassung",
|
|
||||||
"Rückkehr",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const BLATT_STANDORTE: BlattSchema = {
|
|
||||||
name: "Standorte",
|
|
||||||
zweck: "Betriebsstätten, auf die sich Personen beziehen.",
|
|
||||||
schluessel: "Bezeichnung",
|
|
||||||
spalten: [
|
|
||||||
{ name: "Bezeichnung", ziel: "name", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "Wien" },
|
|
||||||
{
|
|
||||||
name: "Land",
|
|
||||||
ziel: "country",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "text" },
|
|
||||||
hinweis: "Ausgeschrieben. Steuert unter anderem, ob eine SV-Nummer verlangt wird.",
|
|
||||||
beispiel: "Österreich",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BLATT_ORGANISATION: BlattSchema = {
|
|
||||||
name: "Organisation",
|
|
||||||
zweck: "Der Aufbau: Gesellschaft, Bereiche, Abteilungen, Teams.",
|
|
||||||
schluessel: "Orgnummer",
|
|
||||||
spalten: [
|
|
||||||
{
|
|
||||||
name: "Orgnummer",
|
|
||||||
ziel: "org_number",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "text" },
|
|
||||||
hinweis: "Eindeutig. Wird von Planstellen als Verweis benutzt.",
|
|
||||||
beispiel: "10000001",
|
|
||||||
},
|
|
||||||
{ name: "Bezeichnung", ziel: "name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Produktion" },
|
|
||||||
{
|
|
||||||
name: "Art",
|
|
||||||
ziel: "unit_type",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "auswahl", werte: ["Gesellschaft", "Bereich", "Abteilung", "Team"] },
|
|
||||||
hinweis: "Genau eine Zeile darf „Gesellschaft“ sein — sie ist die Wurzel.",
|
|
||||||
beispiel: "Bereich",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Übergeordnet",
|
|
||||||
ziel: "parent_org_number",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "text" },
|
|
||||||
hinweis: "Orgnummer der übergeordneten Einheit. Nur bei „Gesellschaft“ leer.",
|
|
||||||
beispiel: "10000000",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Gültig ab",
|
|
||||||
ziel: "valid_from",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "datum" },
|
|
||||||
hinweis: "Leer = heute.",
|
|
||||||
beispiel: "01.01.2020",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Gültig bis",
|
|
||||||
ziel: "valid_to",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "datum" },
|
|
||||||
hinweis: "Leer = unbefristet.",
|
|
||||||
beispiel: "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BLATT_JOBKATALOG: BlattSchema = {
|
|
||||||
name: "Jobkatalog",
|
|
||||||
zweck: "Die Tätigkeiten, auf die Planstellen verweisen.",
|
|
||||||
schluessel: "Jobcode",
|
|
||||||
spalten: [
|
|
||||||
{ name: "Jobcode", ziel: "code", pflicht: true, typ: { art: "text" }, hinweis: "Eindeutig.", beispiel: "50000123" },
|
|
||||||
{
|
|
||||||
name: "Bezeichnung",
|
|
||||||
ziel: "title",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "text" },
|
|
||||||
hinweis: "",
|
|
||||||
beispiel: "Maschinenführer:in",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BLATT_PLANSTELLEN: BlattSchema = {
|
|
||||||
name: "Planstellen",
|
|
||||||
zweck: "Die Stellen selbst — unabhängig davon, wer sie besetzt.",
|
|
||||||
schluessel: "Planstellennummer",
|
|
||||||
spalten: [
|
|
||||||
{
|
|
||||||
name: "Planstellennummer",
|
|
||||||
ziel: "position_number",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "text" },
|
|
||||||
hinweis: "Eindeutig. Personen verweisen darauf.",
|
|
||||||
beispiel: "60000124",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Orgnummer",
|
|
||||||
ziel: "org_number",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "text" },
|
|
||||||
hinweis: "Muss im Blatt „Organisation“ oder bereits im System stehen.",
|
|
||||||
beispiel: "10000001",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Jobcode",
|
|
||||||
ziel: "job_code",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "text" },
|
|
||||||
hinweis: "Muss im Blatt „Jobkatalog“ oder bereits im System stehen.",
|
|
||||||
beispiel: "50000123",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Leitung",
|
|
||||||
ziel: "is_chief",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "janein" },
|
|
||||||
hinweis: "Ja = Leitungsstelle der Einheit. Höchstens eine je Einheit.",
|
|
||||||
beispiel: "nein",
|
|
||||||
},
|
|
||||||
{ name: "Gültig ab", ziel: "valid_from", pflicht: false, typ: { art: "datum" }, hinweis: "Leer = heute.", beispiel: "01.01.2020" },
|
|
||||||
{ name: "Gültig bis", ziel: "valid_to", pflicht: false, typ: { art: "datum" }, hinweis: "", beispiel: "" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BLATT_PERSONEN: BlattSchema = {
|
|
||||||
name: "Personen",
|
|
||||||
zweck: "Stammdaten, Vertrag und die Planstelle, auf der die Person sitzt.",
|
|
||||||
schluessel: "Personalnummer",
|
|
||||||
spalten: [
|
|
||||||
{
|
|
||||||
name: "Personalnummer",
|
|
||||||
ziel: "personnel_number",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "ganzzahl" },
|
|
||||||
hinweis: "Eindeutig. Historie und Angehörige verweisen darauf.",
|
|
||||||
beispiel: "2219",
|
|
||||||
},
|
|
||||||
{ name: "Vorname", ziel: "first_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Sabine" },
|
|
||||||
{ name: "Nachname", ziel: "last_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Aigner" },
|
|
||||||
{
|
|
||||||
name: "Geschlecht",
|
|
||||||
ziel: "gender",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "auswahl", werte: ["m", "w"] },
|
|
||||||
hinweis: "Wird für die SV-Nummer und die Anrede gebraucht.",
|
|
||||||
beispiel: "w",
|
|
||||||
},
|
|
||||||
{ name: "Geburtsdatum", ziel: "birth_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "15.08.1968" },
|
|
||||||
{
|
|
||||||
name: "SV-Nummer",
|
|
||||||
ziel: "sv_nummer",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "text" },
|
|
||||||
hinweis: "Zehnstellig. Prüfziffer und Geburtsdatum müssen zusammenpassen.",
|
|
||||||
beispiel: "7960 150868",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Staatsbürgerschaft",
|
|
||||||
ziel: "nationality",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "text" },
|
|
||||||
hinweis: "Leer = Österreich.",
|
|
||||||
beispiel: "Österreich",
|
|
||||||
},
|
|
||||||
{ name: "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: "Adresse", ziel: "address", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Hauptstraße 1" },
|
|
||||||
{ name: "PLZ", ziel: "postal_code", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "1010" },
|
|
||||||
{ name: "Ort", ziel: "city", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Wien" },
|
|
||||||
{ name: "Land", ziel: "address_country", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "Österreich" },
|
|
||||||
{
|
|
||||||
name: "Titel vorangestellt",
|
|
||||||
ziel: "title_prefix",
|
|
||||||
pflicht: false,
|
|
||||||
// Feste Liste, weil die Datenbank eine CHECK-Bedingung darauf hat.
|
|
||||||
// Ohne die Aufzählung hier bräche der Import erst beim Schreiben ab.
|
|
||||||
typ: { art: "liste", werte: TITLE_PREFIXES },
|
|
||||||
hinweis: "Mehrere mit Semikolon. In einer CSV die Zelle in Anführungszeichen setzen.",
|
|
||||||
beispiel: "Mag.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Titel nachgestellt",
|
|
||||||
ziel: "title_suffix",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "liste", werte: TITLE_SUFFIXES },
|
|
||||||
hinweis: "Mehrere mit Semikolon.",
|
|
||||||
beispiel: "MSc",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Tätigkeit",
|
|
||||||
ziel: "job_title",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "text" },
|
|
||||||
hinweis: "Die Bezeichnung der Person; kann von der Planstelle abweichen.",
|
|
||||||
beispiel: "Projektingenieur:in",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Standort",
|
|
||||||
ziel: "location",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "text" },
|
|
||||||
hinweis: "Muss im Blatt „Standorte“ oder bereits im System stehen.",
|
|
||||||
beispiel: "Wien",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Planstellennummer",
|
|
||||||
ziel: "position_number",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "text" },
|
|
||||||
hinweis: "Muss im Blatt „Planstellen“ oder bereits im System stehen und frei sein.",
|
|
||||||
beispiel: "60000124",
|
|
||||||
},
|
|
||||||
{ name: "Eintritt", ziel: "entry_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "01.03.2015" },
|
|
||||||
{
|
|
||||||
name: "Austritt",
|
|
||||||
ziel: "exit_date",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "datum" },
|
|
||||||
hinweis: "Leer = weiterhin beschäftigt. Muss nach dem Eintritt liegen.",
|
|
||||||
beispiel: "",
|
|
||||||
},
|
|
||||||
{ name: "Austrittsgrund", ziel: "exit_reason", pflicht: false, typ: { art: "text" }, hinweis: "Pflicht, wenn ein Austritt steht.", beispiel: "" },
|
|
||||||
{
|
|
||||||
name: "Beschäftigung",
|
|
||||||
ziel: "employment_type",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "auswahl", werte: ["Vollzeit", "Teilzeit"] },
|
|
||||||
hinweis: "Leer = Vollzeit.",
|
|
||||||
beispiel: "Vollzeit",
|
|
||||||
},
|
|
||||||
{ name: "Wochenstunden", ziel: "weekly_hours", pflicht: false, typ: { art: "zahl" }, hinweis: "Leer = 38,5.", beispiel: "38,5" },
|
|
||||||
{
|
|
||||||
name: "Arbeitstage",
|
|
||||||
ziel: "work_days",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "liste", werte: WOCHENTAGE },
|
|
||||||
hinweis: "Leer = Mo;Di;Mi;Do;Fr.",
|
|
||||||
beispiel: "Mo;Di;Mi;Do;Fr",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Vertragsart",
|
|
||||||
ziel: "contract_type",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "auswahl", werte: ["unbefristet", "befristet"] },
|
|
||||||
hinweis: "Leer = unbefristet.",
|
|
||||||
beispiel: "unbefristet",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Befristet bis",
|
|
||||||
ziel: "contract_end_date",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "datum" },
|
|
||||||
hinweis: "Pflicht bei „befristet“, sonst leer.",
|
|
||||||
beispiel: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Verwendungsgruppe",
|
|
||||||
ziel: "paygrade",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "auswahl", werte: ["A", "B", "C", "D", "E", "F"] },
|
|
||||||
hinweis: "Leer = B.",
|
|
||||||
beispiel: "C",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Kollektivvertrag",
|
|
||||||
ziel: "collective_agreement",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "auswahl", werte: ["Handel", "Süßwaren"] },
|
|
||||||
hinweis: "Leer = Handel.",
|
|
||||||
beispiel: "Handel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Beschäftigtengruppe",
|
|
||||||
ziel: "worker_type",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "auswahl", werte: ["Angestellte:r", "Arbeiter:in"] },
|
|
||||||
hinweis: "Leer = Angestellte:r.",
|
|
||||||
beispiel: "Angestellte:r",
|
|
||||||
},
|
|
||||||
{ name: "Monatsgehalt brutto", ziel: "monthly_salary_gross", pflicht: false, typ: { art: "zahl" }, hinweis: "", beispiel: "3450,00" },
|
|
||||||
{
|
|
||||||
name: "Herkunft",
|
|
||||||
ziel: "source",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "auswahl", werte: ["Intern", "Extern"] },
|
|
||||||
hinweis: "Leer = Extern.",
|
|
||||||
beispiel: "Extern",
|
|
||||||
},
|
|
||||||
{ name: "Betriebsrat", ziel: "is_betriebsrat", pflicht: false, typ: { art: "janein" }, hinweis: "Leer = nein.", beispiel: "nein" },
|
|
||||||
{ name: "Dienstwagen", ziel: "has_dienstwagen", pflicht: false, typ: { art: "janein" }, hinweis: "", beispiel: "nein" },
|
|
||||||
{ name: "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: "Abwesenheit ab",
|
|
||||||
ziel: "karenz_start_date",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "datum" },
|
|
||||||
hinweis: "Beginn einer Langzeitabwesenheit.",
|
|
||||||
beispiel: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Rückkehr geplant",
|
|
||||||
ziel: "karenz_return_date",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "datum" },
|
|
||||||
hinweis: "Pflicht, wenn „Abwesenheit ab“ steht.",
|
|
||||||
beispiel: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Abwesenheitsart",
|
|
||||||
ziel: "absence_type",
|
|
||||||
pflicht: false,
|
|
||||||
typ: { art: "auswahl", werte: ABSENCE_TYPES },
|
|
||||||
hinweis: "Pflicht, wenn „Abwesenheit ab“ steht.",
|
|
||||||
beispiel: "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BLATT_HISTORIE: BlattSchema = {
|
|
||||||
name: "Historie",
|
|
||||||
zweck: "Ereignisse je Person. Alles, was vor dem Eintritt liegt, wird abgewiesen.",
|
|
||||||
schluessel: null,
|
|
||||||
spalten: [
|
|
||||||
{
|
|
||||||
name: "Personalnummer",
|
|
||||||
ziel: "personnel_number",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "ganzzahl" },
|
|
||||||
hinweis: "Muss im Blatt „Personen“ oder bereits im System stehen.",
|
|
||||||
beispiel: "2219",
|
|
||||||
},
|
|
||||||
{ name: "Datum", ziel: "event_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "01.03.2015" },
|
|
||||||
{
|
|
||||||
name: "Ereignis",
|
|
||||||
ziel: "event_type",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "auswahl", werte: EREIGNIS },
|
|
||||||
hinweis: "",
|
|
||||||
beispiel: "Eintritt",
|
|
||||||
},
|
|
||||||
{ name: "Beschreibung", ziel: "description", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Eintritt als Projektingenieur:in" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BLATT_ANGEHOERIGE: BlattSchema = {
|
|
||||||
name: "Angehörige",
|
|
||||||
zweck: "Angehörige je Person.",
|
|
||||||
schluessel: null,
|
|
||||||
spalten: [
|
|
||||||
{ name: "Personalnummer", ziel: "personnel_number", pflicht: true, typ: { art: "ganzzahl" }, hinweis: "", beispiel: "2219" },
|
|
||||||
{ name: "Vorname", ziel: "first_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Julian" },
|
|
||||||
{ name: "Nachname", ziel: "last_name", pflicht: true, typ: { art: "text" }, hinweis: "", beispiel: "Aigner" },
|
|
||||||
{
|
|
||||||
name: "Verhältnis",
|
|
||||||
ziel: "relationship",
|
|
||||||
pflicht: true,
|
|
||||||
typ: { art: "auswahl", werte: VERHAELTNIS },
|
|
||||||
hinweis: "",
|
|
||||||
beispiel: "Kind",
|
|
||||||
},
|
|
||||||
{ name: "Geburtsdatum", ziel: "birth_date", pflicht: true, typ: { art: "datum" }, hinweis: "", beispiel: "04.06.2014" },
|
|
||||||
{ name: "SV-Nummer", ziel: "sv_nummer", pflicht: false, typ: { art: "text" }, hinweis: "", beispiel: "" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reihenfolge = Ladereihenfolge.
|
|
||||||
*
|
|
||||||
* Standorte, Organisation und Jobkatalog müssen stehen, bevor Planstellen
|
|
||||||
* darauf verweisen können; Personen brauchen Planstellen; Historie und
|
|
||||||
* Angehörige brauchen Personen.
|
|
||||||
*/
|
|
||||||
export const BLAETTER: BlattSchema[] = [
|
|
||||||
BLATT_STANDORTE,
|
|
||||||
BLATT_ORGANISATION,
|
|
||||||
BLATT_JOBKATALOG,
|
|
||||||
BLATT_PLANSTELLEN,
|
|
||||||
BLATT_PERSONEN,
|
|
||||||
BLATT_HISTORIE,
|
|
||||||
BLATT_ANGEHOERIGE,
|
|
||||||
];
|
|
||||||
|
|
||||||
export function blattSchema(name: string): BlattSchema | undefined {
|
|
||||||
const gesucht = name.trim().toLowerCase();
|
|
||||||
return BLAETTER.find((b) => b.name.toLowerCase() === gesucht);
|
|
||||||
}
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
import { todayIso } from "@/lib/format";
|
|
||||||
import { normalizeSvnr, svnrErrorMessage, validateSvnr } from "@/lib/svnr";
|
|
||||||
import type { ImportSheet } from "./parse";
|
|
||||||
import { BLAETTER, blattSchema, type BlattSchema, type Spalte } from "./schema";
|
|
||||||
import { alsAufzaehlung, alsDatum, alsGanzzahl, alsJaNein, alsListe, alsZahl } from "./werte";
|
|
||||||
|
|
||||||
// Prüfung einer eingelesenen Datei.
|
|
||||||
//
|
|
||||||
// Zwei Grundsätze, beide bewusst:
|
|
||||||
//
|
|
||||||
// 1. **Es wird alles gemeldet, nicht das erste.** Wer eine Datei mit 800
|
|
||||||
// Zeilen hochlädt, will nicht achthundertmal hochladen. Deshalb sammelt
|
|
||||||
// jede Prüfung weiter, statt abzubrechen.
|
|
||||||
//
|
|
||||||
// 2. **Der Bestand kommt als Parameter, nicht aus der Datenbank.** Damit
|
|
||||||
// bleibt diese Datei rein und ohne Verbindung testbar — und die
|
|
||||||
// Abfragen stehen an einer Stelle, wo man sie sieht (load.ts).
|
|
||||||
|
|
||||||
export type Befund = {
|
|
||||||
blatt: string;
|
|
||||||
/** Zeilennummer wie in Excel; null für Probleme am ganzen Blatt. */
|
|
||||||
zeile: number | null;
|
|
||||||
spalte: string | null;
|
|
||||||
wert?: string;
|
|
||||||
meldung: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Was bereits in der Datenbank steht — für Verweise und Doppelprüfungen. */
|
|
||||||
export type Bestand = {
|
|
||||||
standorte: Map<string, string>;
|
|
||||||
orgNummern: Map<string, string>;
|
|
||||||
jobCodes: Map<string, string>;
|
|
||||||
/** Planstellennummer → { id, heuteBesetzt } */
|
|
||||||
planstellen: Map<string, { id: string; besetzt: boolean }>;
|
|
||||||
personalnummern: Map<number, string>;
|
|
||||||
emails: Set<string>;
|
|
||||||
svNummern: Set<string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LEERER_BESTAND: Bestand = {
|
|
||||||
standorte: new Map(),
|
|
||||||
orgNummern: new Map(),
|
|
||||||
jobCodes: new Map(),
|
|
||||||
planstellen: new Map(),
|
|
||||||
personalnummern: new Map(),
|
|
||||||
emails: new Set(),
|
|
||||||
svNummern: new Set(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Zeile = { zeile: number; werte: Record<string, unknown> };
|
|
||||||
export type Datensatz = Record<string, Zeile[]>;
|
|
||||||
|
|
||||||
export type Pruefergebnis = {
|
|
||||||
fehler: Befund[];
|
|
||||||
hinweise: Befund[];
|
|
||||||
datensatz: Datensatz;
|
|
||||||
/** Was angelegt würde, je Blatt. */
|
|
||||||
anzahl: Record<string, number>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function leseFeld(spalte: Spalte, roh: string): { wert: unknown; fehler: string | null } {
|
|
||||||
if (roh === "") return { wert: null, fehler: null };
|
|
||||||
|
|
||||||
switch (spalte.typ.art) {
|
|
||||||
case "text":
|
|
||||||
return { wert: roh, fehler: null };
|
|
||||||
case "datum": {
|
|
||||||
const d = alsDatum(roh);
|
|
||||||
return d ? { wert: d, fehler: null } : { wert: null, fehler: "Kein gültiges Datum. Erwartet: 31.12.2026 oder 2026-12-31." };
|
|
||||||
}
|
|
||||||
case "zahl": {
|
|
||||||
const n = alsZahl(roh);
|
|
||||||
return n !== null ? { wert: n, fehler: null } : { wert: null, fehler: "Keine Zahl." };
|
|
||||||
}
|
|
||||||
case "ganzzahl": {
|
|
||||||
const n = alsGanzzahl(roh);
|
|
||||||
return n !== null ? { wert: n, fehler: null } : { wert: null, fehler: "Keine ganze Zahl." };
|
|
||||||
}
|
|
||||||
case "janein": {
|
|
||||||
const b = alsJaNein(roh);
|
|
||||||
return b !== null ? { wert: b, fehler: null } : { wert: null, fehler: "Erwartet: ja oder nein." };
|
|
||||||
}
|
|
||||||
case "liste": {
|
|
||||||
const l = alsListe(roh);
|
|
||||||
if (spalte.typ.werte) {
|
|
||||||
const erlaubt = spalte.typ.werte;
|
|
||||||
const treffer: string[] = [];
|
|
||||||
for (const t of l) {
|
|
||||||
const k = alsAufzaehlung(t, erlaubt);
|
|
||||||
if (!k) return { wert: null, fehler: `„${t}“ ist unbekannt. Erlaubt: ${erlaubt.join(", ")}.` };
|
|
||||||
treffer.push(k);
|
|
||||||
}
|
|
||||||
return { wert: treffer, fehler: null };
|
|
||||||
}
|
|
||||||
return { wert: l, fehler: null };
|
|
||||||
}
|
|
||||||
case "auswahl": {
|
|
||||||
const w = alsAufzaehlung(roh, spalte.typ.werte);
|
|
||||||
return w ? { wert: w, fehler: null } : { wert: null, fehler: `Unbekannter Wert. Erlaubt: ${spalte.typ.werte.join(", ")}.` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function blattLesen(schema: BlattSchema, blatt: ImportSheet, fehler: Befund[]): Zeile[] {
|
|
||||||
const vorhanden = new Set(blatt.spalten);
|
|
||||||
for (const s of schema.spalten) {
|
|
||||||
if (s.pflicht && !vorhanden.has(s.name)) {
|
|
||||||
fehler.push({ blatt: schema.name, zeile: null, spalte: s.name, meldung: "Pflichtspalte fehlt." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const s of blatt.spalten) {
|
|
||||||
if (!schema.spalten.some((x) => x.name === s)) {
|
|
||||||
fehler.push({ blatt: schema.name, zeile: 1, spalte: s, meldung: "Unbekannte Spalte — wird nicht übernommen." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const zeilen: Zeile[] = [];
|
|
||||||
const schluesselGesehen = new Map<string, number>();
|
|
||||||
|
|
||||||
for (const r of blatt.zeilen) {
|
|
||||||
const werte: Record<string, unknown> = {};
|
|
||||||
for (const s of schema.spalten) {
|
|
||||||
const roh = (r.werte[s.name] ?? "").trim();
|
|
||||||
if (roh === "" && s.pflicht) {
|
|
||||||
fehler.push({ blatt: schema.name, zeile: r.zeile, spalte: s.name, meldung: "Pflichtfeld ist leer." });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const { wert, fehler: f } = leseFeld(s, roh);
|
|
||||||
if (f) fehler.push({ blatt: schema.name, zeile: r.zeile, spalte: s.name, wert: roh, meldung: f });
|
|
||||||
else werte[s.ziel] = wert;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schema.schluessel) {
|
|
||||||
const sp = schema.spalten.find((x) => x.name === schema.schluessel)!;
|
|
||||||
const k = werte[sp.ziel];
|
|
||||||
if (k !== undefined && k !== null) {
|
|
||||||
const schluessel = String(k);
|
|
||||||
const zuvor = schluesselGesehen.get(schluessel);
|
|
||||||
if (zuvor !== undefined) {
|
|
||||||
fehler.push({
|
|
||||||
blatt: schema.name,
|
|
||||||
zeile: r.zeile,
|
|
||||||
spalte: schema.schluessel,
|
|
||||||
wert: schluessel,
|
|
||||||
meldung: `Kommt bereits in Zeile ${zuvor} vor. Welche Zeile gälte, hinge an der Reihenfolge in der Datei.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
schluesselGesehen.set(schluessel, r.zeile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
zeilen.push({ zeile: r.zeile, werte });
|
|
||||||
}
|
|
||||||
return zeilen;
|
|
||||||
}
|
|
||||||
|
|
||||||
const s = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
|
|
||||||
const n = (v: unknown): number | null => (typeof v === "number" ? v : null);
|
|
||||||
|
|
||||||
export function pruefe(blaetter: ImportSheet[], bestand: Bestand = LEERER_BESTAND): Pruefergebnis {
|
|
||||||
const fehler: Befund[] = [];
|
|
||||||
const hinweise: Befund[] = [];
|
|
||||||
const datensatz: Datensatz = {};
|
|
||||||
|
|
||||||
for (const blatt of blaetter) {
|
|
||||||
const schema = blattSchema(blatt.name);
|
|
||||||
if (!schema) {
|
|
||||||
hinweise.push({
|
|
||||||
blatt: blatt.name,
|
|
||||||
zeile: null,
|
|
||||||
spalte: null,
|
|
||||||
meldung: `Unbekanntes Blatt — wird übergangen. Erwartet: ${BLAETTER.map((b) => b.name).join(", ")}.`,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
datensatz[schema.name] = blattLesen(schema, blatt, fehler);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hole = (name: string) => datensatz[name] ?? [];
|
|
||||||
const melde = (blatt: string, zeile: number, spalte: string | null, meldung: string, wert?: string) =>
|
|
||||||
fehler.push({ blatt, zeile, spalte, wert, meldung });
|
|
||||||
|
|
||||||
// ── Standorte und Organisation ────────────────────────────────
|
|
||||||
const standorte = new Set([...bestand.standorte.keys()]);
|
|
||||||
for (const z of hole("Standorte")) {
|
|
||||||
const name = s(z.werte.name);
|
|
||||||
if (name) {
|
|
||||||
if (bestand.standorte.has(name)) melde("Standorte", z.zeile, "Bezeichnung", "Gibt es bereits.", name);
|
|
||||||
standorte.add(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const orgNummern = new Set([...bestand.orgNummern.keys()]);
|
|
||||||
const orgArten = new Map<string, string>();
|
|
||||||
let wurzeln = 0;
|
|
||||||
for (const z of hole("Organisation")) {
|
|
||||||
const nr = s(z.werte.org_number);
|
|
||||||
const art = s(z.werte.unit_type);
|
|
||||||
if (nr) {
|
|
||||||
if (bestand.orgNummern.has(nr)) melde("Organisation", z.zeile, "Orgnummer", "Gibt es bereits.", nr);
|
|
||||||
orgNummern.add(nr);
|
|
||||||
if (art) orgArten.set(nr, art);
|
|
||||||
}
|
|
||||||
if (art === "Gesellschaft") wurzeln++;
|
|
||||||
}
|
|
||||||
for (const z of hole("Organisation")) {
|
|
||||||
const eltern = s(z.werte.parent_org_number);
|
|
||||||
const art = s(z.werte.unit_type);
|
|
||||||
if (art === "Gesellschaft") {
|
|
||||||
if (eltern) melde("Organisation", z.zeile, "Übergeordnet", "Die Gesellschaft ist die Wurzel und hat nichts über sich.");
|
|
||||||
} else if (!eltern) {
|
|
||||||
melde("Organisation", z.zeile, "Übergeordnet", "Pflicht für alles ausser der Gesellschaft.");
|
|
||||||
} else if (!orgNummern.has(eltern)) {
|
|
||||||
melde("Organisation", z.zeile, "Übergeordnet", "Steht weder in der Datei noch im System.", eltern);
|
|
||||||
} else if (eltern === s(z.werte.org_number)) {
|
|
||||||
melde("Organisation", z.zeile, "Übergeordnet", "Eine Einheit kann sich nicht selbst übergeordnet sein.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (wurzeln > 1) {
|
|
||||||
fehler.push({ blatt: "Organisation", zeile: null, spalte: "Art", meldung: `Es gibt ${wurzeln} Zeilen „Gesellschaft“; genau eine ist erlaubt.` });
|
|
||||||
}
|
|
||||||
if (wurzeln === 0 && bestand.orgNummern.size === 0 && hole("Organisation").length > 0) {
|
|
||||||
fehler.push({ blatt: "Organisation", zeile: null, spalte: "Art", meldung: "Keine Zeile „Gesellschaft“ — der Aufbau hätte keine Wurzel." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Jobkatalog und Planstellen ────────────────────────────────
|
|
||||||
const jobCodes = new Set([...bestand.jobCodes.keys()]);
|
|
||||||
for (const z of hole("Jobkatalog")) {
|
|
||||||
const code = s(z.werte.code);
|
|
||||||
if (code) {
|
|
||||||
if (bestand.jobCodes.has(code)) melde("Jobkatalog", z.zeile, "Jobcode", "Gibt es bereits.", code);
|
|
||||||
jobCodes.add(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const planstellen = new Set([...bestand.planstellen.keys()]);
|
|
||||||
const leitungJeEinheit = new Map<string, number>();
|
|
||||||
for (const z of hole("Planstellen")) {
|
|
||||||
const nr = s(z.werte.position_number);
|
|
||||||
const org = s(z.werte.org_number);
|
|
||||||
const job = s(z.werte.job_code);
|
|
||||||
if (nr) {
|
|
||||||
if (bestand.planstellen.has(nr)) melde("Planstellen", z.zeile, "Planstellennummer", "Gibt es bereits.", nr);
|
|
||||||
planstellen.add(nr);
|
|
||||||
}
|
|
||||||
if (org && !orgNummern.has(org)) melde("Planstellen", z.zeile, "Orgnummer", "Steht weder in der Datei noch im System.", org);
|
|
||||||
if (job && !jobCodes.has(job)) melde("Planstellen", z.zeile, "Jobcode", "Steht weder in der Datei noch im System.", job);
|
|
||||||
if (z.werte.is_chief === true && org) {
|
|
||||||
const anzahl = (leitungJeEinheit.get(org) ?? 0) + 1;
|
|
||||||
leitungJeEinheit.set(org, anzahl);
|
|
||||||
if (anzahl === 2) {
|
|
||||||
melde("Planstellen", z.zeile, "Leitung", `Die Einheit ${org} hätte damit zwei Leitungsstellen.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const von = s(z.werte.valid_from);
|
|
||||||
const bis = s(z.werte.valid_to);
|
|
||||||
if (von && bis && bis < von) melde("Planstellen", z.zeile, "Gültig bis", "Liegt vor „Gültig ab“.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Personen ──────────────────────────────────────────────────
|
|
||||||
const heute = todayIso();
|
|
||||||
const personalnummern = new Set([...bestand.personalnummern.keys()]);
|
|
||||||
const emails = new Set([...bestand.emails]);
|
|
||||||
const svNummern = new Set([...bestand.svNummern]);
|
|
||||||
const belegtePlanstellen = new Map<string, number>();
|
|
||||||
const eintritte = new Map<number, string>();
|
|
||||||
|
|
||||||
for (const z of hole("Personen")) {
|
|
||||||
const pnr = n(z.werte.personnel_number);
|
|
||||||
const w = z.werte;
|
|
||||||
|
|
||||||
if (pnr !== null) {
|
|
||||||
if (bestand.personalnummern.has(pnr)) melde("Personen", z.zeile, "Personalnummer", "Gibt es bereits im System.", String(pnr));
|
|
||||||
personalnummern.add(pnr);
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = s(w.email)?.toLowerCase();
|
|
||||||
if (email) {
|
|
||||||
if (emails.has(email)) melde("Personen", z.zeile, "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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const geburt = s(w.birth_date);
|
|
||||||
const svRoh = s(w.sv_nummer);
|
|
||||||
if (svRoh) {
|
|
||||||
const f = validateSvnr(svRoh, geburt);
|
|
||||||
if (f) melde("Personen", z.zeile, "SV-Nummer", svnrErrorMessage(f), svRoh);
|
|
||||||
else {
|
|
||||||
const norm = normalizeSvnr(svRoh);
|
|
||||||
if (svNummern.has(norm)) melde("Personen", z.zeile, "SV-Nummer", "Kommt bereits vor.", svRoh);
|
|
||||||
svNummern.add(norm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (geburt && geburt > heute) melde("Personen", z.zeile, "Geburtsdatum", "Liegt in der Zukunft.", geburt);
|
|
||||||
|
|
||||||
const standort = s(w.location);
|
|
||||||
if (standort && !standorte.has(standort)) melde("Personen", z.zeile, "Standort", "Steht weder in der Datei noch im System.", standort);
|
|
||||||
|
|
||||||
const stelle = s(w.position_number);
|
|
||||||
if (stelle) {
|
|
||||||
if (!planstellen.has(stelle)) {
|
|
||||||
melde("Personen", z.zeile, "Planstellennummer", "Steht weder in der Datei noch im System.", stelle);
|
|
||||||
} else if (bestand.planstellen.get(stelle)?.besetzt) {
|
|
||||||
melde("Personen", z.zeile, "Planstellennummer", "Diese Planstelle ist bereits besetzt.", stelle);
|
|
||||||
}
|
|
||||||
const zuvor = belegtePlanstellen.get(stelle);
|
|
||||||
// Doppelbesetzung ist in diesem Modell nicht bloss unsauber, sondern
|
|
||||||
// verboten — die Datenbank hat dafür einen Teilindex.
|
|
||||||
if (zuvor !== undefined) melde("Personen", z.zeile, "Planstellennummer", `Wird bereits in Zeile ${zuvor} besetzt.`, stelle);
|
|
||||||
else belegtePlanstellen.set(stelle, z.zeile);
|
|
||||||
}
|
|
||||||
|
|
||||||
const eintritt = s(w.entry_date);
|
|
||||||
const austritt = s(w.exit_date);
|
|
||||||
if (pnr !== null && eintritt) eintritte.set(pnr, eintritt);
|
|
||||||
if (eintritt && geburt && eintritt <= geburt) melde("Personen", z.zeile, "Eintritt", "Liegt vor dem Geburtsdatum.", eintritt);
|
|
||||||
// Gleicher Tag ist erlaubt — jemand, der den Dienst nicht antritt, tritt
|
|
||||||
// am selben Tag ein und aus. Die Datenbank sieht das genauso.
|
|
||||||
if (austritt && eintritt && austritt < eintritt) melde("Personen", z.zeile, "Austritt", "Liegt vor dem Eintritt.", austritt);
|
|
||||||
if (austritt && !s(w.exit_reason)) melde("Personen", z.zeile, "Austrittsgrund", "Pflicht, sobald ein Austritt steht.");
|
|
||||||
|
|
||||||
if (s(w.contract_type) === "befristet" && !s(w.contract_end_date)) {
|
|
||||||
melde("Personen", z.zeile, "Befristet bis", "Pflicht bei einem befristeten Vertrag.");
|
|
||||||
}
|
|
||||||
if (s(w.contract_type) !== "befristet" && s(w.contract_end_date)) {
|
|
||||||
melde("Personen", z.zeile, "Befristet bis", "Nur bei „befristet“ erlaubt.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const abVon = s(w.karenz_start_date);
|
|
||||||
if (abVon) {
|
|
||||||
if (!s(w.karenz_return_date)) melde("Personen", z.zeile, "Rückkehr geplant", "Pflicht, sobald eine Abwesenheit beginnt.");
|
|
||||||
if (!s(w.absence_type)) melde("Personen", z.zeile, "Abwesenheitsart", "Pflicht, sobald eine Abwesenheit beginnt.");
|
|
||||||
if (eintritt && abVon < eintritt) melde("Personen", z.zeile, "Abwesenheit ab", "Liegt vor dem Eintritt.", abVon);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (abVon && s(w.karenz_return_date) && s(w.karenz_return_date)! < (eintritt ?? "")) {
|
|
||||||
melde("Personen", z.zeile, "Rückkehr geplant", "Liegt vor dem Eintritt.", s(w.karenz_return_date)!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vollzeit bedeutet in diesem Kollektivvertrag genau 38,5 Stunden, und
|
|
||||||
// Teilzeit alles darunter über null. Die Datenbank hat dafür eine
|
|
||||||
// CHECK-Bedingung; ohne diese Prüfung bräche der Import erst beim
|
|
||||||
// Schreiben ab — mit einer Meldung ohne Zeilennummer. Genau so ist der
|
|
||||||
// erste Durchstich gescheitert.
|
|
||||||
const stunden = n(w.weekly_hours);
|
|
||||||
const beschaeftigung = s(w.employment_type) ?? "Vollzeit";
|
|
||||||
if (stunden !== null) {
|
|
||||||
if (beschaeftigung === "Vollzeit" && stunden !== 38.5) {
|
|
||||||
melde("Personen", z.zeile, "Wochenstunden", "Vollzeit sind genau 38,5 Stunden. Für weniger „Teilzeit“ eintragen.", String(stunden));
|
|
||||||
}
|
|
||||||
if (beschaeftigung === "Teilzeit" && (stunden <= 0 || stunden >= 38.5)) {
|
|
||||||
melde("Personen", z.zeile, "Wochenstunden", "Teilzeit liegt zwischen 0 und 38,5 Stunden.", String(stunden));
|
|
||||||
}
|
|
||||||
} else if (beschaeftigung === "Teilzeit") {
|
|
||||||
melde("Personen", z.zeile, "Wochenstunden", "Pflicht bei Teilzeit — sonst gälten 38,5 und damit Vollzeit.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const tage = w.work_days;
|
|
||||||
if (Array.isArray(tage) && tage.length === 0) {
|
|
||||||
melde("Personen", z.zeile, "Arbeitstage", "Mindestens ein Tag. Leer lassen für Mo–Fr.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Historie und Angehörige ───────────────────────────────────
|
|
||||||
for (const z of hole("Historie")) {
|
|
||||||
const pnr = n(z.werte.personnel_number);
|
|
||||||
if (pnr === null) continue;
|
|
||||||
if (!personalnummern.has(pnr)) {
|
|
||||||
melde("Historie", z.zeile, "Personalnummer", "Steht weder in der Datei noch im System.", String(pnr));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Nur für Personen aus derselben Datei; für bereits vorhandene kennt
|
|
||||||
// diese Funktion das Eintrittsdatum nicht, und die Datenbank prüft es
|
|
||||||
// ohnehin ein zweites Mal.
|
|
||||||
const eintritt = eintritte.get(pnr) ?? null;
|
|
||||||
const datum = s(z.werte.event_date);
|
|
||||||
// Die Datenbank weist das ohnehin ab (trg_history_not_before_entry) —
|
|
||||||
// aber mitten im Einfügen und mit einer Meldung ohne Zeilennummer.
|
|
||||||
if (eintritt && datum && datum < eintritt) {
|
|
||||||
melde("Historie", z.zeile, "Datum", `Liegt vor dem Eintritt am ${eintritt}.`, datum);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const z of hole("Angehörige")) {
|
|
||||||
const pnr = n(z.werte.personnel_number);
|
|
||||||
if (pnr === null) continue;
|
|
||||||
if (!personalnummern.has(pnr)) {
|
|
||||||
melde("Angehörige", z.zeile, "Personalnummer", "Steht weder in der Datei noch im System.", String(pnr));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const sv = s(z.werte.sv_nummer);
|
|
||||||
const geb = s(z.werte.birth_date);
|
|
||||||
if (sv) {
|
|
||||||
const f = validateSvnr(sv, geb);
|
|
||||||
if (f) melde("Angehörige", z.zeile, "SV-Nummer", svnrErrorMessage(f), sv);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const anzahl: Record<string, number> = {};
|
|
||||||
for (const b of BLAETTER) anzahl[b.name] = (datensatz[b.name] ?? []).length;
|
|
||||||
|
|
||||||
return { fehler, hinweise, datensatz, anzahl };
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
// Aus einer Zelle wird ein Wert.
|
|
||||||
//
|
|
||||||
// Jede Funktion hier liefert entweder den Wert oder `null` — nie eine
|
|
||||||
// Näherung. Was sich nicht eindeutig lesen lässt, ist ein Fehler, den die
|
|
||||||
// Person in der Datei korrigieren soll. Raten wäre hier besonders teuer: aus
|
|
||||||
// „03.08.26" könnte 2026-08-03 oder 2003-08-26 werden, und beides sähe im
|
|
||||||
// Ergebnis unauffällig aus.
|
|
||||||
|
|
||||||
/** Kalendertag als „JJJJ-MM-TT". Ohne Zeitzone, weil ein Geburtstag keine hat. */
|
|
||||||
export type IsoTag = string;
|
|
||||||
|
|
||||||
function tagAusTeilen(jahr: number, monat: number, tag: number): IsoTag | null {
|
|
||||||
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null;
|
|
||||||
const d = new Date(Date.UTC(jahr, monat - 1, tag));
|
|
||||||
// Fängt den 31. Februar: Date rechnet ihn stillschweigend in den 3. März um.
|
|
||||||
if (d.getUTCFullYear() !== jahr || d.getUTCMonth() !== monat - 1 || d.getUTCDate() !== tag) return null;
|
|
||||||
const m = String(monat).padStart(2, "0");
|
|
||||||
const t = String(tag).padStart(2, "0");
|
|
||||||
return `${jahr}-${m}-${t}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Datum aus einer Zelle.
|
|
||||||
*
|
|
||||||
* Angenommen werden ISO (2026-08-03), österreichisch (3.8.2026, 03.08.2026)
|
|
||||||
* und mit Schrägstrich (03/08/2026). Zweistellige Jahre werden **abgelehnt**:
|
|
||||||
* bei Geburtsdaten liegt das Jahrhundert nicht fest, und eine Regel wie
|
|
||||||
* „unter 30 heisst 20xx" produziert lautlos Personen, die noch nicht geboren
|
|
||||||
* sind.
|
|
||||||
*/
|
|
||||||
export function alsDatum(roh: string): IsoTag | null {
|
|
||||||
const s = roh.trim();
|
|
||||||
if (!s) return null;
|
|
||||||
|
|
||||||
const iso = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
|
||||||
if (iso) return tagAusTeilen(Number(iso[1]), Number(iso[2]), Number(iso[3]));
|
|
||||||
|
|
||||||
const deutsch = s.match(/^(\d{1,2})[.\/](\d{1,2})[.\/](\d{4})\.?$/);
|
|
||||||
if (deutsch) return tagAusTeilen(Number(deutsch[3]), Number(deutsch[2]), Number(deutsch[1]));
|
|
||||||
|
|
||||||
// Excel-Serienzahl — kommt vor, wenn die Spalte nicht als Datum formatiert
|
|
||||||
// ist. Tag 1 ist der 01.01.1900, und Excel kennt einen 29.02.1900, den es
|
|
||||||
// nie gab; ab Serie 60 muss deshalb ein Tag abgezogen werden.
|
|
||||||
if (/^\d{1,6}$/.test(s)) {
|
|
||||||
const serie = Number(s);
|
|
||||||
if (serie >= 1 && serie < 100000) {
|
|
||||||
const tage = serie >= 60 ? serie - 1 : serie;
|
|
||||||
const d = new Date(Date.UTC(1899, 11, 31) + tage * 86400000);
|
|
||||||
return tagAusTeilen(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zahl aus einer Zelle, mit Komma **oder** Punkt als Dezimaltrenner.
|
|
||||||
*
|
|
||||||
* „1.234,5" und „1,234.5" sind beide gebräuchlich und bedeuten dasselbe. Das
|
|
||||||
* letzte Trennzeichen entscheidet, die übrigen sind Tausenderpunkte.
|
|
||||||
*/
|
|
||||||
export function alsZahl(roh: string): number | null {
|
|
||||||
let s = roh.trim().replace(/\s/g, "");
|
|
||||||
if (!s) return null;
|
|
||||||
s = s.replace(/€|EUR/gi, "");
|
|
||||||
|
|
||||||
const letztesKomma = s.lastIndexOf(",");
|
|
||||||
const letzterPunkt = s.lastIndexOf(".");
|
|
||||||
if (letztesKomma >= 0 && letzterPunkt >= 0) {
|
|
||||||
const dezimal = letztesKomma > letzterPunkt ? "," : ".";
|
|
||||||
const tausender = dezimal === "," ? "." : ",";
|
|
||||||
s = s.split(tausender).join("").replace(dezimal, ".");
|
|
||||||
} else if (letztesKomma >= 0) {
|
|
||||||
// Ein einzelnes Komma ist im deutschsprachigen Raum ein Dezimaltrenner,
|
|
||||||
// auch bei „1,234" — das sind 1,234 und nicht 1234.
|
|
||||||
s = s.replace(",", ".");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!/^-?\d*\.?\d+$/.test(s)) return null;
|
|
||||||
const n = Number(s);
|
|
||||||
return Number.isFinite(n) ? n : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function alsGanzzahl(roh: string): number | null {
|
|
||||||
const n = alsZahl(roh);
|
|
||||||
return n !== null && Number.isInteger(n) ? n : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const JA = new Set(["ja", "j", "x", "wahr", "true", "1", "y", "yes"]);
|
|
||||||
const NEIN = new Set(["nein", "n", "falsch", "false", "0", "no", "-"]);
|
|
||||||
|
|
||||||
/** Ja/Nein aus einer Zelle. Alles andere ist ein Fehler, nicht „nein". */
|
|
||||||
export function alsJaNein(roh: string): boolean | null {
|
|
||||||
const s = roh.trim().toLowerCase();
|
|
||||||
if (!s) return null;
|
|
||||||
if (JA.has(s)) return true;
|
|
||||||
if (NEIN.has(s)) return false;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liste aus einer Zelle — für Titel und Arbeitstage.
|
|
||||||
*
|
|
||||||
* Getrennt wird an Semikolon oder Komma. Leere Glieder fallen weg, damit
|
|
||||||
* „Mo, Di, " nicht in einem leeren Arbeitstag endet.
|
|
||||||
*/
|
|
||||||
export function alsListe(roh: string): string[] {
|
|
||||||
return roh
|
|
||||||
.split(/[;,]/)
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Einen Wert einer Aufzählung zuordnen, unabhängig von Gross- und
|
|
||||||
* Kleinschreibung und von Leerzeichen.
|
|
||||||
*
|
|
||||||
* Zurück kommt die **kanonische** Schreibweise aus der Datenbank, nicht die
|
|
||||||
* aus der Datei: „vollzeit" wird zu „Vollzeit", weil die Spalte ein enum ist
|
|
||||||
* und alles andere die Zeile beim Einfügen abweisen würde.
|
|
||||||
*/
|
|
||||||
export function alsAufzaehlung<T extends string>(roh: string, erlaubt: readonly T[]): T | null {
|
|
||||||
const s = roh.trim().toLowerCase();
|
|
||||||
if (!s) return null;
|
|
||||||
return erlaubt.find((w) => w.toLowerCase() === s) ?? null;
|
|
||||||
}
|
|
||||||
40
lib/notes.ts
40
lib/notes.ts
@@ -1,32 +1,26 @@
|
|||||||
import type { Tx } from "./db";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import { fetchAllRows } from "./supabase/query";
|
||||||
import type { Database } from "./supabase/types";
|
import type { Database } from "./supabase/types";
|
||||||
|
|
||||||
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
|
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
|
||||||
employeeName: string;
|
employeeName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// „Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
|
// "Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
|
||||||
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie
|
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon wer
|
||||||
// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt).
|
// sie verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). Zwei
|
||||||
//
|
// einfache Queries, in JS gemerged — gleiches Muster wie loadEventHistory
|
||||||
// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
|
// in lib/reports-data.ts, da der handgeschriebene Database-Typ keine
|
||||||
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
|
// relationalen Embeddings für eine einzelne verschachtelte Query kennt.
|
||||||
// direkten Zugang ist es schlicht ein Join.
|
export async function loadOpenNotes(supabase: SupabaseClient<Database>): Promise<OpenNote[]> {
|
||||||
export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
|
const [{ data: notes }, employees] = await Promise.all([
|
||||||
const rows = await tx
|
supabase.from("employee_notes").select("*").eq("done", false).order("created_at", { ascending: false }),
|
||||||
.selectFrom("employee_notes as n")
|
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
|
||||||
.leftJoin("employees as e", "e.id", "n.employee_id")
|
]);
|
||||||
.selectAll("n")
|
|
||||||
.select(["e.first_name", "e.last_name"])
|
|
||||||
.where("n.done", "=", false)
|
|
||||||
.orderBy("n.created_at", "desc")
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
return rows.map((row) => {
|
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||||||
const { first_name, last_name, ...note } = row;
|
return (notes ?? []).map((n) => {
|
||||||
return {
|
const emp = employeeById.get(n.employee_id);
|
||||||
...(note as Database["public"]["Tables"]["employee_notes"]["Row"]),
|
return { ...n, employeeName: emp ? `${emp.first_name} ${emp.last_name}` : "Unbekannt" };
|
||||||
employeeName: first_name && last_name ? `${first_name} ${last_name}` : "Unbekannt",
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
// Die SAP-OM-Berichtslinie, abgeleitet aus dem Organisationsbaum.
|
|
||||||
//
|
|
||||||
// Dieselbe Regel steckt als om_reporting_lines() in der Datenbank. Zwei
|
|
||||||
// Fassungen derselben Regel driften auseinander, deshalb prüft
|
|
||||||
// tests/integration/om-reporting.test.ts beide gegen denselben Bestand.
|
|
||||||
// Hier liegt sie zusätzlich, weil das Organigramm zu einem Stichtag ohnehin
|
|
||||||
// im Anwendungscode gerechnet wird und ein Datenbank-Roundtrip je
|
|
||||||
// Stichtagswechsel nichts brächte.
|
|
||||||
//
|
|
||||||
// Regel:
|
|
||||||
// Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der
|
|
||||||
// eigenen Einheit. Wer selbst die Leitung innehat, an die Leitung der
|
|
||||||
// übergeordneten Einheit.
|
|
||||||
//
|
|
||||||
// Aufwärtsregel: Ist diese Leitung unbesetzt oder langzeitabwesend, geht es
|
|
||||||
// weiter nach oben. Eine unbesetzte Abteilungsleitung braucht damit keine
|
|
||||||
// Sonderbehandlung — sie wird übersprungen.
|
|
||||||
|
|
||||||
export type OmUnit = { id: string; parentId: string | null };
|
|
||||||
|
|
||||||
export type OmHolder = {
|
|
||||||
employeeId: string;
|
|
||||||
positionId: string;
|
|
||||||
orgUnitId: string;
|
|
||||||
isChief: boolean;
|
|
||||||
/** Langzeitabwesend am Stichtag. */
|
|
||||||
absent: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OmReportingLine = {
|
|
||||||
employeeId: string;
|
|
||||||
positionId: string;
|
|
||||||
orgUnitId: string;
|
|
||||||
isChief: boolean;
|
|
||||||
/** Zuständige Leitung, auch wenn abwesend. Null, wenn es keine gibt. */
|
|
||||||
formalManagerId: string | null;
|
|
||||||
/** Nächste besetzte und anwesende Leitung ab der zuständigen Einheit aufwärts. */
|
|
||||||
actingManagerId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `units` und `holders` beschreiben den Stand zu genau einem Stichtag —
|
|
||||||
* gültige Einheiten und laufende Besetzungen. Die Zeitlogik bleibt bewusst
|
|
||||||
* draußen, damit diese Funktion nur eine Sache tut.
|
|
||||||
*/
|
|
||||||
export function resolveReportingLines(units: OmUnit[], holders: OmHolder[]): OmReportingLine[] {
|
|
||||||
const parentOf = new Map(units.map((u) => [u.id, u.parentId]));
|
|
||||||
const chiefOfUnit = new Map<string, OmHolder>();
|
|
||||||
for (const h of holders) {
|
|
||||||
if (h.isChief) chiefOfUnit.set(h.orgUnitId, h);
|
|
||||||
}
|
|
||||||
|
|
||||||
return holders.map((h) => {
|
|
||||||
// Leitungen suchen ab der übergeordneten Einheit, alle anderen ab der
|
|
||||||
// eigenen — sonst berichtete eine Leitung an sich selbst.
|
|
||||||
const baseUnitId = h.isChief ? (parentOf.get(h.orgUnitId) ?? null) : h.orgUnitId;
|
|
||||||
|
|
||||||
const formal = baseUnitId ? (chiefOfUnit.get(baseUnitId) ?? null) : null;
|
|
||||||
|
|
||||||
// Aufwärts, bis eine besetzte und anwesende Leitung gefunden ist. Der
|
|
||||||
// Zyklusschutz ist kein Selbstzweck: parent_id ist eine gewöhnliche
|
|
||||||
// Spalte, und ein fehlerhafter Import kann einen Ring erzeugen.
|
|
||||||
let actingManagerId: string | null = null;
|
|
||||||
const seen = new Set<string>();
|
|
||||||
let unitId: string | null = baseUnitId;
|
|
||||||
while (unitId && !seen.has(unitId)) {
|
|
||||||
seen.add(unitId);
|
|
||||||
const chief = chiefOfUnit.get(unitId);
|
|
||||||
if (chief && !chief.absent && chief.employeeId !== h.employeeId) {
|
|
||||||
actingManagerId = chief.employeeId;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
unitId = parentOf.get(unitId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
employeeId: h.employeeId,
|
|
||||||
positionId: h.positionId,
|
|
||||||
orgUnitId: h.orgUnitId,
|
|
||||||
isChief: h.isChief,
|
|
||||||
formalManagerId: formal?.employeeId ?? null,
|
|
||||||
actingManagerId,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
135
lib/org.ts
135
lib/org.ts
@@ -1,123 +1,48 @@
|
|||||||
import type { Tx } from "./db";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { Database } from "./supabase/types";
|
import type { Database } from "./supabase/types";
|
||||||
|
|
||||||
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher
|
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||||
// aus divisions/departments/teams zusammengesteckt wurde, ergibt sich jetzt
|
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||||
// aus org_units.parent_id — und damit funktioniert es auch für eine fünfte
|
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||||
// Ebene, ohne dass hier etwas zu ändern wäre.
|
|
||||||
|
|
||||||
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
|
|
||||||
|
|
||||||
export type OrgUnit = {
|
|
||||||
id: string;
|
|
||||||
org_number: string;
|
|
||||||
name: string;
|
|
||||||
parent_id: string | null;
|
|
||||||
unit_type: OrgUnitType;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||||
|
|
||||||
export type OrgMaps = {
|
export type OrgMaps = {
|
||||||
units: Map<string, OrgUnit>;
|
divisions: Map<string, Division>;
|
||||||
/** Tiefensuche ab der Wurzel: eine Einheit steht immer hinter ihrem Elternteil. */
|
departments: Map<string, Department>;
|
||||||
unitList: OrgUnit[];
|
teams: Map<string, Team>;
|
||||||
/** Abstand zur Wurzel; die Wurzel selbst hat 0. */
|
|
||||||
depthOf: Map<string, number>;
|
|
||||||
childrenOf: Map<string | null, OrgUnit[]>;
|
|
||||||
locations: Map<string, Location>;
|
locations: Map<string, Location>;
|
||||||
|
divisionList: Division[];
|
||||||
locationList: Location[];
|
locationList: Location[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
|
// Org reference data is tiny (9 divisions / 16 departments / 35 teams / 5
|
||||||
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
|
// locations) — fetched whole and joined client-side rather than per-row.
|
||||||
export async function loadOrgMaps(tx: Tx): Promise<OrgMaps> {
|
export async function loadOrgMaps(supabase: SupabaseClient<Database>): Promise<OrgMaps> {
|
||||||
const [units, locations] = await Promise.all([
|
const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([
|
||||||
tx
|
supabase.from("divisions").select("*").order("name"),
|
||||||
.selectFrom("org_units")
|
supabase.from("departments").select("*"),
|
||||||
.select(["id", "org_number", "name", "parent_id", "unit_type"])
|
supabase.from("teams").select("*"),
|
||||||
.orderBy("org_number")
|
supabase.from("locations").select("*").order("name"),
|
||||||
.execute(),
|
|
||||||
tx.selectFrom("locations").selectAll().orderBy("name").execute(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return buildOrgMaps(units as OrgUnit[], locations as Location[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */
|
|
||||||
export function buildOrgMaps(units: OrgUnit[], locations: Location[]): OrgMaps {
|
|
||||||
const childrenOf = new Map<string | null, OrgUnit[]>();
|
|
||||||
for (const u of units) {
|
|
||||||
const list = childrenOf.get(u.parent_id) ?? [];
|
|
||||||
list.push(u);
|
|
||||||
childrenOf.set(u.parent_id, list);
|
|
||||||
}
|
|
||||||
for (const list of childrenOf.values()) list.sort((a, b) => a.name.localeCompare(b.name, "de"));
|
|
||||||
|
|
||||||
const unitList: OrgUnit[] = [];
|
|
||||||
const depthOf = new Map<string, number>();
|
|
||||||
const walk = (parentId: string | null, depth: number) => {
|
|
||||||
for (const u of childrenOf.get(parentId) ?? []) {
|
|
||||||
unitList.push(u);
|
|
||||||
depthOf.set(u.id, depth);
|
|
||||||
walk(u.id, depth + 1);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk(null, 0);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
units: new Map(units.map((u) => [u.id, u])),
|
divisions: new Map((divisions ?? []).map((d) => [d.id, d])),
|
||||||
unitList,
|
departments: new Map((departments ?? []).map((d) => [d.id, d])),
|
||||||
depthOf,
|
teams: new Map((teams ?? []).map((t) => [t.id, t])),
|
||||||
childrenOf,
|
locations: new Map((locations ?? []).map((l) => [l.id, l])),
|
||||||
locations: new Map(locations.map((l) => [l.id, l])),
|
divisionList: divisions ?? [],
|
||||||
locationList: locations,
|
locationList: locations ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wurzel zuerst, die Einheit selbst zuletzt. */
|
export function breadcrumbFor(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null) {
|
||||||
export function ancestorsOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit[] {
|
const division = divisionId ? orgMaps.divisions.get(divisionId) : undefined;
|
||||||
const chain: OrgUnit[] = [];
|
const team = teamId ? orgMaps.teams.get(teamId) : undefined;
|
||||||
const seen = new Set<string>();
|
const department = team ? orgMaps.departments.get(team.department_id) : undefined;
|
||||||
let current = unitId ? maps.units.get(unitId) : undefined;
|
return { division, department, team };
|
||||||
while (current && !seen.has(current.id)) {
|
|
||||||
seen.add(current.id);
|
|
||||||
chain.unshift(current);
|
|
||||||
current = current.parent_id ? maps.units.get(current.parent_id) : undefined;
|
|
||||||
}
|
|
||||||
return chain;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Die Einheit und alles darunter — die Menge, die ein Filter „Bereich X" meint. */
|
export function breadcrumbLabel(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null): string {
|
||||||
export function subtreeOf(maps: OrgMaps, unitId: string): string[] {
|
const { division, department, team } = breadcrumbFor(orgMaps, divisionId, teamId);
|
||||||
const out: string[] = [];
|
return [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–";
|
||||||
const queue = [unitId];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const id = queue.shift()!;
|
|
||||||
if (seen.has(id)) continue;
|
|
||||||
seen.add(id);
|
|
||||||
out.push(id);
|
|
||||||
for (const child of maps.childrenOf.get(id) ?? []) queue.push(child.id);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* „Produktion › Fertigung › Montage". Die Gesellschaft bleibt weg: sie steht
|
|
||||||
* über allem und trägt in einer Zeile nichts bei.
|
|
||||||
*/
|
|
||||||
export function breadcrumbLabel(maps: OrgMaps, unitId: string | null | undefined): string {
|
|
||||||
const chain = ancestorsOf(maps, unitId).filter((u) => u.unit_type !== "Gesellschaft");
|
|
||||||
return chain.map((u) => u.name).join(" › ") || "–";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Die oberste Einheit unterhalb der Gesellschaft — das, was früher „Bereich" hiess. */
|
|
||||||
export function divisionOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
|
|
||||||
return ancestorsOf(maps, unitId).find((u) => u.unit_type !== "Gesellschaft");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Die Einheit selbst, wenn sie nicht die Gesellschaft ist. */
|
|
||||||
export function unitOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit | undefined {
|
|
||||||
return unitId ? maps.units.get(unitId) : undefined;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,32 @@
|
|||||||
import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import type { Tx } from "./db";
|
import type { OrgEmployee } from "@/components/orgchart/types";
|
||||||
|
import { resolveActingManagers } from "./acting-manager";
|
||||||
import { todayIso } from "./format";
|
import { todayIso } from "./format";
|
||||||
import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting";
|
import { deriveStatusAsOf } from "./reports";
|
||||||
|
import { fetchAllRows } from "./supabase/query";
|
||||||
|
import type { Database } from "./supabase/types";
|
||||||
|
|
||||||
// Das Organigramm, wie es an einem Stichtag stand oder stehen wird.
|
// The Organigramm as it stood (or will stand) on a given date. Three sources
|
||||||
|
// have to be reconciled, because no single one covers the whole timeline:
|
||||||
//
|
//
|
||||||
// Im Altmodell mussten dafür drei Quellen versöhnt werden, weil keine den
|
// past/today employee_assignments — the interval covering `asOf`
|
||||||
// ganzen Zeitstrahl abdeckte: eine mitgeschriebene Zuordnungshistorie für die
|
// future pending_org_changes — effective-dated moves not yet applied
|
||||||
// Vergangenheit, vorgemerkte Änderungen für die Zukunft und die
|
// membership entry/exit/karenz — who counted as staff on that date
|
||||||
// Ein-/Austrittsdaten für die Frage, wer überhaupt dazuzählte.
|
|
||||||
//
|
//
|
||||||
// Im OM-Modell fällt das zusammen. position_assignments ist zeitabhängig, also
|
// See supabase/migrations/*_employee_assignment_history.sql for why the
|
||||||
// beantwortet eine einzige Abfrage „wer besetzte am Stichtag welche
|
// placement timeline is captured by a trigger rather than per-RPC.
|
||||||
// Planstelle" — für Vergangenheit und Zukunft gleichermassen. Wer zu dem
|
|
||||||
// Zeitpunkt keine Planstelle innehatte, war nicht da; eine zweite
|
|
||||||
// Zugehörigkeitsregel braucht es nicht mehr.
|
|
||||||
//
|
|
||||||
// Übrig bleibt die Projektion vorgemerkter Versetzungen: die stehen noch nicht
|
|
||||||
// in position_assignments, weil sie erst am Stichtag geschrieben werden.
|
|
||||||
|
|
||||||
/** Änderungsarten, die jemanden in der Organisation verschieben. */
|
const ORG_COLUMNS =
|
||||||
const PLACEMENT_CHANGES = ["transfer"] as const;
|
"id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level, entry_date, exit_date, karenz_start_date, karenz_return_date, absence_type";
|
||||||
|
|
||||||
|
/** Change types that move someone in the org; the rest only affect status or contract. */
|
||||||
|
const PLACEMENT_CHANGES = ["transfer", "reorg", "promotion"] as const;
|
||||||
|
|
||||||
export type OrgAsOfResult = {
|
export type OrgAsOfResult = {
|
||||||
employees: OrgEmployee[];
|
employees: OrgEmployee[];
|
||||||
vacancies: OrgVacancy[];
|
/** How many placements were projected from not-yet-applied changes. */
|
||||||
/** Wie viele Platzierungen aus noch nicht angewandten Änderungen stammen. */
|
|
||||||
projectedCount: number;
|
projectedCount: number;
|
||||||
/** Frühester Tag, den die Besetzungshistorie tatsächlich abdeckt. */
|
/** Earliest date the assignment history actually covers. */
|
||||||
historyStartsAt: string | null;
|
historyStartsAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,193 +36,196 @@ type EmployeeRow = {
|
|||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
job_title: string;
|
job_title: string;
|
||||||
|
manager_id: string | null;
|
||||||
|
team_id: string | null;
|
||||||
|
division_id: string;
|
||||||
|
is_lead: boolean;
|
||||||
|
org_level: number;
|
||||||
|
entry_date: string;
|
||||||
|
exit_date: string | null;
|
||||||
karenz_start_date: string | null;
|
karenz_start_date: string | null;
|
||||||
karenz_return_date: string | null;
|
karenz_return_date: string | null;
|
||||||
absence_type: string | null;
|
absence_type: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PositionRow = {
|
type AssignmentRow = {
|
||||||
id: string;
|
employee_id: string;
|
||||||
position_number: string;
|
manager_id: string | null;
|
||||||
org_unit_id: string;
|
team_id: string | null;
|
||||||
is_chief: boolean;
|
division_id: string;
|
||||||
jobs: { title: string };
|
job_title: string;
|
||||||
|
is_lead: boolean;
|
||||||
|
org_level: number;
|
||||||
|
valid_from: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AssignmentRow = { employee_id: string; position_id: string };
|
|
||||||
|
|
||||||
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
|
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
|
||||||
|
|
||||||
export async function loadOrgAsOf(tx: Tx, asOf: string): Promise<OrgAsOfResult> {
|
type Placement = { team_id: string | null; division_id: string; job_title: string; is_lead: boolean; org_level: number };
|
||||||
|
|
||||||
|
// Mirrors resolve_manager_for() in supabase/migrations: an IC reports to
|
||||||
|
// their team's lead, a team lead to the division head, and anyone without a
|
||||||
|
// team to the CEO. Only used for employees a pending change actually moves —
|
||||||
|
// everyone else keeps the manager recorded on their assignment, so existing
|
||||||
|
// data that deviates from the rule is never silently "corrected".
|
||||||
|
function resolveManagerFor(placement: Placement, all: { id: string; placement: Placement }[]): string | null {
|
||||||
|
if (placement.team_id && !placement.is_lead) {
|
||||||
|
return all.find((e) => e.placement.team_id === placement.team_id && e.placement.is_lead)?.id ?? null;
|
||||||
|
}
|
||||||
|
if (placement.team_id && placement.is_lead) {
|
||||||
|
return (
|
||||||
|
all.find((e) => e.placement.division_id === placement.division_id && !e.placement.team_id && e.placement.org_level === 1)?.id ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return all.find((e) => e.placement.org_level === 0)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadOrgAsOf(supabase: SupabaseClient<Database>, asOf: string): Promise<OrgAsOfResult> {
|
||||||
const today = todayIso();
|
const today = todayIso();
|
||||||
|
|
||||||
// Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise
|
const [allEmployees, assignments, teams, departments, pending] = await Promise.all([
|
||||||
// Nachladen weg: sechs Abfragen, jede vollständig.
|
fetchAllRows(() => supabase.from("employees").select(ORG_COLUMNS).order("id")),
|
||||||
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
|
fetchAllRows(() =>
|
||||||
tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(),
|
supabase
|
||||||
|
.from("employee_assignments")
|
||||||
tx
|
.select("employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from")
|
||||||
.selectFrom("om_positions as p")
|
.lte("valid_from", asOf)
|
||||||
.innerJoin("jobs as j", "j.id", "p.job_id")
|
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
|
||||||
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "j.title"])
|
.order("employee_id")
|
||||||
.where("p.valid_from", "<=", asOf)
|
),
|
||||||
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
|
fetchAllRows(() => supabase.from("teams").select("id, department_id").order("id")),
|
||||||
.orderBy("p.id")
|
fetchAllRows(() => supabase.from("departments").select("id, division_id").order("id")),
|
||||||
.execute(),
|
|
||||||
|
|
||||||
tx
|
|
||||||
.selectFrom("position_assignments")
|
|
||||||
.select(["employee_id", "position_id"])
|
|
||||||
.where("valid_from", "<=", asOf)
|
|
||||||
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
|
|
||||||
.orderBy("employee_id")
|
|
||||||
.execute(),
|
|
||||||
|
|
||||||
tx
|
|
||||||
.selectFrom("employees")
|
|
||||||
.select([
|
|
||||||
"id",
|
|
||||||
"personnel_number",
|
|
||||||
"first_name",
|
|
||||||
"last_name",
|
|
||||||
"job_title",
|
|
||||||
"karenz_start_date",
|
|
||||||
"karenz_return_date",
|
|
||||||
"absence_type",
|
|
||||||
])
|
|
||||||
.orderBy("id")
|
|
||||||
.execute(),
|
|
||||||
|
|
||||||
asOf > today
|
asOf > today
|
||||||
? tx
|
? fetchAllRows(() =>
|
||||||
.selectFrom("pending_org_changes")
|
supabase
|
||||||
.select(["employee_id", "effective_date", "payload"])
|
.from("pending_org_changes")
|
||||||
.where("status", "=", "pending")
|
.select("employee_id, change_type, effective_date, payload")
|
||||||
.where("effective_date", "<=", asOf)
|
.eq("status", "pending")
|
||||||
.where("change_type", "in", [...PLACEMENT_CHANGES])
|
.lte("effective_date", asOf)
|
||||||
.orderBy("effective_date")
|
.in("change_type", [...PLACEMENT_CHANGES])
|
||||||
.execute()
|
.order("effective_date")
|
||||||
|
)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
|
|
||||||
tx.selectFrom("position_assignments").select("valid_from").orderBy("valid_from").limit(1).executeTakeFirst(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return resolveOrgSnapshot({
|
return resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending });
|
||||||
asOf,
|
|
||||||
units: units.map((u) => ({ id: u.id, parentId: u.parent_id })),
|
|
||||||
// Der Join liefert den Jobtitel flach; die reine Funktion erwartet ihn
|
|
||||||
// verschachtelt, weil sie so auch aus einem Testbestand gefüttert wird.
|
|
||||||
positions: positions.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
position_number: p.position_number,
|
|
||||||
org_unit_id: p.org_unit_id,
|
|
||||||
is_chief: p.is_chief,
|
|
||||||
jobs: { title: p.title },
|
|
||||||
})),
|
|
||||||
assignments: assignments as AssignmentRow[],
|
|
||||||
employees: employees as EmployeeRow[],
|
|
||||||
pending: pending as PendingRow[],
|
|
||||||
historyStartsAt: earliest?.valid_from ?? null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// The pure half of the above: everything that turns the four row sets into a
|
||||||
* Der reine Teil: aus den Zeilen den Stand machen, ohne Datenbank, damit die
|
// snapshot, with no Supabase client in sight, so the reconciliation rules can
|
||||||
* Regeln direkt prüfbar sind.
|
// be tested directly.
|
||||||
*/
|
|
||||||
export function resolveOrgSnapshot({
|
export function resolveOrgSnapshot({
|
||||||
asOf,
|
asOf,
|
||||||
units,
|
|
||||||
positions,
|
|
||||||
assignments,
|
|
||||||
employees: allEmployees,
|
employees: allEmployees,
|
||||||
|
assignments,
|
||||||
|
teams,
|
||||||
|
departments,
|
||||||
pending,
|
pending,
|
||||||
historyStartsAt,
|
|
||||||
}: {
|
}: {
|
||||||
asOf: string;
|
asOf: string;
|
||||||
units: OmUnit[];
|
|
||||||
positions: PositionRow[];
|
|
||||||
assignments: AssignmentRow[];
|
|
||||||
employees: EmployeeRow[];
|
employees: EmployeeRow[];
|
||||||
|
assignments: AssignmentRow[];
|
||||||
|
teams: { id: string; department_id: string }[];
|
||||||
|
departments: { id: string; division_id: string }[];
|
||||||
pending: PendingRow[];
|
pending: PendingRow[];
|
||||||
historyStartsAt: string | null;
|
|
||||||
}): OrgAsOfResult {
|
}): OrgAsOfResult {
|
||||||
const positionById = new Map(positions.map((p) => [p.id, p]));
|
const assignmentByEmployee = new Map(assignments.map((a) => [a.employee_id, a]));
|
||||||
const employeeById = new Map(allEmployees.map((e) => [e.id, e]));
|
// A projected move names only the target team; its division follows from
|
||||||
|
// the team's department, the same way the DB trigger derives it.
|
||||||
|
const departmentDivision = new Map(departments.map((d) => [d.id, d.division_id]));
|
||||||
|
const teamDivision = new Map(
|
||||||
|
teams.flatMap((t) => {
|
||||||
|
const divisionId = departmentDivision.get(t.department_id);
|
||||||
|
return divisionId ? [[t.id, divisionId] as const] : [];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Dieselbe Ableitung wie in deriveStatusAsOf() und in om_reporting_lines(),
|
// Employed (or on leave) on that date — the same derivation the Berichte
|
||||||
// damit die drei nie auseinanderlaufen können.
|
// page uses, so the two can never disagree on who counted when.
|
||||||
const isAbsent = (e: EmployeeRow) =>
|
const staff = allEmployees.filter((e) => {
|
||||||
e.karenz_start_date !== null &&
|
const status = deriveStatusAsOf(e, asOf);
|
||||||
e.karenz_start_date <= asOf &&
|
return status === "Aktiv" || status === "Karenz";
|
||||||
(e.karenz_return_date === null || asOf < e.karenz_return_date);
|
|
||||||
|
|
||||||
const positionOf = new Map<string, string>();
|
|
||||||
for (const a of assignments) {
|
|
||||||
if (positionById.has(a.position_id) && employeeById.has(a.employee_id)) positionOf.set(a.employee_id, a.position_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Die Zukunft projizieren: nach effective_date sortiert, eine spätere
|
|
||||||
// Versetzung gewinnt.
|
|
||||||
const moved = new Set<string>();
|
|
||||||
for (const change of pending) {
|
|
||||||
if (!positionOf.has(change.employee_id)) continue;
|
|
||||||
const targetId = (change.payload as { target_position_id?: string }).target_position_id;
|
|
||||||
if (!targetId || !positionById.has(targetId)) continue;
|
|
||||||
positionOf.set(change.employee_id, targetId);
|
|
||||||
moved.add(change.employee_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const holders: OmHolder[] = [];
|
|
||||||
for (const [employeeId, positionId] of positionOf) {
|
|
||||||
const position = positionById.get(positionId)!;
|
|
||||||
holders.push({
|
|
||||||
employeeId,
|
|
||||||
positionId,
|
|
||||||
orgUnitId: position.org_unit_id,
|
|
||||||
isChief: position.is_chief,
|
|
||||||
absent: isAbsent(employeeById.get(employeeId)!),
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const lines = resolveReportingLines(units, holders);
|
const resolved = staff.map((e) => {
|
||||||
|
const a = assignmentByEmployee.get(e.id);
|
||||||
const employees: OrgEmployee[] = lines.map((l) => {
|
|
||||||
const e = employeeById.get(l.employeeId)!;
|
|
||||||
const position = positionById.get(l.positionId)!;
|
|
||||||
return {
|
return {
|
||||||
id: e.id,
|
employee: e,
|
||||||
personnel_number: e.personnel_number,
|
managerId: a ? a.manager_id : e.manager_id,
|
||||||
first_name: e.first_name,
|
placement: {
|
||||||
last_name: e.last_name,
|
team_id: a ? a.team_id : e.team_id,
|
||||||
// Die Tätigkeit der Planstelle, nicht das Freitextfeld auf der Person:
|
division_id: a ? a.division_id : e.division_id,
|
||||||
// bei einer projizierten Versetzung ist nur die erste schon richtig.
|
job_title: a ? a.job_title : e.job_title,
|
||||||
job_title: position.jobs.title,
|
is_lead: a ? a.is_lead : e.is_lead,
|
||||||
manager_id: l.actingManagerId,
|
org_level: a ? a.org_level : e.org_level,
|
||||||
// Nur setzen, wenn eine Vertretung im Spiel ist — sonst zeigt die
|
} satisfies Placement,
|
||||||
// Oberfläche zweimal dieselbe Person an.
|
|
||||||
formal_manager_id: l.formalManagerId === l.actingManagerId ? null : l.formalManagerId,
|
|
||||||
absent: isAbsent(e),
|
|
||||||
absence_type: e.absence_type,
|
|
||||||
org_unit_id: l.orgUnitId,
|
|
||||||
is_chief: l.isChief,
|
|
||||||
position_id: l.positionId,
|
|
||||||
position_number: position.position_number,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unbesetzte Planstellen. Im Altmodell waren offene Stellen eine eigene
|
// Project the future. Ordered by effective_date, so a later move wins.
|
||||||
// Tabelle neben der Organisation; hier sind sie schlicht das Komplement.
|
const byId = new Map(resolved.map((r) => [r.employee.id, r]));
|
||||||
const besetzt = new Set(positionOf.values());
|
const moved = new Set<string>();
|
||||||
const vacancies: OrgVacancy[] = positions
|
for (const change of pending) {
|
||||||
.filter((p) => !besetzt.has(p.id))
|
const target = byId.get(change.employee_id);
|
||||||
.map((p) => ({
|
if (!target) continue;
|
||||||
position_id: p.id,
|
const payload = change.payload as { new_team_id?: string; target_team_id?: string; new_title?: string };
|
||||||
position_number: p.position_number,
|
const newTeamId = payload.new_team_id ?? payload.target_team_id ?? null;
|
||||||
job_title: p.jobs.title,
|
if (newTeamId) {
|
||||||
org_unit_id: p.org_unit_id,
|
target.placement.team_id = newTeamId;
|
||||||
is_chief: p.is_chief,
|
const divisionId = teamDivision.get(newTeamId);
|
||||||
}));
|
if (divisionId) target.placement.division_id = divisionId;
|
||||||
|
moved.add(target.employee.id);
|
||||||
return { employees, vacancies, projectedCount: moved.size, historyStartsAt };
|
}
|
||||||
|
if (payload.new_title) target.placement.job_title = payload.new_title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: a moved employee's manager follows from the *projected*
|
||||||
|
// org, not the one they left — and the lead of their new team may itself
|
||||||
|
// have moved in this same batch.
|
||||||
|
for (const r of resolved) {
|
||||||
|
if (moved.has(r.employee.id)) r.managerId = resolveManagerFor(r.placement, resolved.map((x) => ({ id: x.employee.id, placement: x.placement })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A manager who had not joined yet, or had already left, is not in this
|
||||||
|
// set — without re-rooting, their whole reporting line would silently
|
||||||
|
// vanish from the chart rather than showing up one level higher.
|
||||||
|
const presentIds = new Set(resolved.map((r) => r.employee.id));
|
||||||
|
const recordedManagerOf = (r: (typeof resolved)[number]) =>
|
||||||
|
r.managerId && presentIds.has(r.managerId) ? r.managerId : null;
|
||||||
|
|
||||||
|
// While somebody is on a long-term absence their reports roll up to the
|
||||||
|
// next present level. Derived here rather than written to the database:
|
||||||
|
// the absent person stays formally in charge, and the stand-in is only a
|
||||||
|
// stand-in — which is why both ids travel to the UI.
|
||||||
|
const absentIds = new Set(resolved.filter((r) => deriveStatusAsOf(r.employee, asOf) === "Karenz").map((r) => r.employee.id));
|
||||||
|
const acting = resolveActingManagers(
|
||||||
|
resolved.map((r) => ({ id: r.employee.id, managerId: recordedManagerOf(r), absent: absentIds.has(r.employee.id) }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const employees: OrgEmployee[] = resolved.map((r) => {
|
||||||
|
const { actingManagerId, coveredForId } = acting.get(r.employee.id) ?? { actingManagerId: null, coveredForId: null };
|
||||||
|
return {
|
||||||
|
id: r.employee.id,
|
||||||
|
personnel_number: r.employee.personnel_number,
|
||||||
|
first_name: r.employee.first_name,
|
||||||
|
last_name: r.employee.last_name,
|
||||||
|
job_title: r.placement.job_title,
|
||||||
|
manager_id: actingManagerId,
|
||||||
|
formal_manager_id: coveredForId,
|
||||||
|
absent: absentIds.has(r.employee.id),
|
||||||
|
absence_type: r.employee.absence_type,
|
||||||
|
team_id: r.placement.team_id,
|
||||||
|
division_id: r.placement.division_id,
|
||||||
|
is_lead: r.placement.is_lead,
|
||||||
|
org_level: r.placement.org_level,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const historyStartsAt = assignments.reduce<string | null>(
|
||||||
|
(min, a) => (min === null || a.valid_from < min ? a.valid_from : min),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
return { employees, projectedCount: moved.size, historyStartsAt };
|
||||||
}
|
}
|
||||||
|
|||||||
143
lib/placement.ts
143
lib/placement.ts
@@ -1,143 +0,0 @@
|
|||||||
import { sql, type Tx } from "./db";
|
|
||||||
|
|
||||||
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
|
|
||||||
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
|
|
||||||
//
|
|
||||||
// employees ──A008──> position_assignments ──> om_positions ──> org_units
|
|
||||||
// └────────> jobs
|
|
||||||
//
|
|
||||||
// Das ist der Grund, warum es diese Datei gibt: die Verkettung braucht es an
|
|
||||||
// einem Dutzend Stellen, und sie zeitrichtig aufzulösen ist die Arbeit.
|
|
||||||
|
|
||||||
export type Placement = {
|
|
||||||
employeeId: string;
|
|
||||||
positionId: string;
|
|
||||||
positionNumber: string;
|
|
||||||
orgUnitId: string;
|
|
||||||
isChief: boolean;
|
|
||||||
jobTitle: string;
|
|
||||||
validFrom: string;
|
|
||||||
validTo: string | null;
|
|
||||||
/** Die Besetzung läuft am Stichtag; sonst ist es die zuletzt beendete. */
|
|
||||||
current: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Row = {
|
|
||||||
employee_id: string;
|
|
||||||
valid_from: string;
|
|
||||||
valid_to: string | null;
|
|
||||||
position_id: string;
|
|
||||||
position_number: string;
|
|
||||||
org_unit_id: string;
|
|
||||||
is_chief: boolean;
|
|
||||||
job_title: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function toPlacement(row: Row, asOf: string): Placement {
|
|
||||||
return {
|
|
||||||
employeeId: row.employee_id,
|
|
||||||
positionId: row.position_id,
|
|
||||||
positionNumber: row.position_number,
|
|
||||||
orgUnitId: row.org_unit_id,
|
|
||||||
isChief: row.is_chief,
|
|
||||||
jobTitle: row.job_title,
|
|
||||||
validFrom: row.valid_from,
|
|
||||||
validTo: row.valid_to,
|
|
||||||
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Die am Stichtag laufende Besetzung je Person — und für alle, die zu dem
|
|
||||||
* Zeitpunkt keine hatten, die zuletzt beendete. Ohne diesen Rückfall stünde
|
|
||||||
* bei jeder ausgetretenen Person „–" statt der Stelle, die sie innehatte.
|
|
||||||
*/
|
|
||||||
export function pickPlacements(rows: Row[], asOf: string): Map<string, Placement> {
|
|
||||||
const byEmployee = new Map<string, Placement>();
|
|
||||||
for (const row of rows) {
|
|
||||||
const p = toPlacement(row, asOf);
|
|
||||||
const best = byEmployee.get(p.employeeId);
|
|
||||||
if (!best) {
|
|
||||||
byEmployee.set(p.employeeId, p);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Laufend schlägt beendet; unter beendeten gewinnt die jüngste.
|
|
||||||
if (p.current && !best.current) byEmployee.set(p.employeeId, p);
|
|
||||||
else if (p.current === best.current && p.validFrom > best.validFrom) byEmployee.set(p.employeeId, p);
|
|
||||||
}
|
|
||||||
return byEmployee;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadPlacements(
|
|
||||||
tx: Tx,
|
|
||||||
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
|
|
||||||
): Promise<Map<string, Placement>> {
|
|
||||||
if (employeeIds?.length === 0) return new Map();
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
let q = tx
|
|
||||||
.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");
|
|
||||||
|
|
||||||
if (employeeIds) q = q.where("pa.employee_id", "in", employeeIds);
|
|
||||||
|
|
||||||
return pickPlacements((await q.execute()) as Row[], asOf);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
|
|
||||||
// Sie steht nirgends als Spalte; om_reporting_lines() rechnet sie aus dem
|
|
||||||
// Baum aus. formal_manager_id ist die zuständige Leitung, acting_manager_id
|
|
||||||
// die nächste besetzte und anwesende darüber — beides, damit sich in der
|
|
||||||
// Oberfläche zeigen lässt, dass eine Vertretung im Spiel ist, statt sie
|
|
||||||
// stillschweigend als die echte Führungskraft auszugeben.
|
|
||||||
|
|
||||||
export type ReportingLine = {
|
|
||||||
employee_id: string;
|
|
||||||
position_id: string;
|
|
||||||
org_unit_id: string;
|
|
||||||
is_chief: boolean;
|
|
||||||
formal_manager_id: string | null;
|
|
||||||
acting_manager_id: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher —
|
|
||||||
* bei der Detailseite wandern damit neun Zeilen über die Leitung statt
|
|
||||||
* achthundert.
|
|
||||||
*/
|
|
||||||
export async function loadReportingLines(
|
|
||||||
tx: Tx,
|
|
||||||
asOf: string,
|
|
||||||
filter?: { employeeId?: string; actingManagerId?: string }
|
|
||||||
): Promise<ReportingLine[]> {
|
|
||||||
const conditions = [sql`true`];
|
|
||||||
if (filter?.employeeId) conditions.push(sql`employee_id = ${filter.employeeId}::uuid`);
|
|
||||||
if (filter?.actingManagerId) conditions.push(sql`acting_manager_id = ${filter.actingManagerId}::uuid`);
|
|
||||||
|
|
||||||
const result = await sql<ReportingLine>`
|
|
||||||
select * from om_reporting_lines(${asOf}::date)
|
|
||||||
where ${sql.join(conditions, sql` and `)}
|
|
||||||
`.execute(tx);
|
|
||||||
|
|
||||||
return result.rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Wie loadReportingLines, aber als Karte über die Personen-Kennung. */
|
|
||||||
export async function loadReportingLineMap(tx: Tx, asOf: string): Promise<Map<string, ReportingLine>> {
|
|
||||||
const lines = await loadReportingLines(tx, asOf);
|
|
||||||
return new Map(lines.map((l) => [l.employee_id, l]));
|
|
||||||
}
|
|
||||||
146
lib/positions.ts
146
lib/positions.ts
@@ -1,133 +1,41 @@
|
|||||||
import type { Tx } from "./db";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import { todayIso } from "./format";
|
import { breadcrumbLabel, loadOrgMaps } from "./org";
|
||||||
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org";
|
import type { Database } from "./supabase/types";
|
||||||
|
|
||||||
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
|
|
||||||
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
|
|
||||||
// zweites Objekt daneben, das mit der Organisation synchron gehalten werden
|
|
||||||
// müsste.
|
|
||||||
|
|
||||||
export type OpenPositionResolved = {
|
export type OpenPositionResolved = {
|
||||||
id: string;
|
id: string;
|
||||||
position_number: string;
|
position_number: string;
|
||||||
title: string;
|
title: string;
|
||||||
org_unit_id: string;
|
team_id: string;
|
||||||
is_chief: boolean;
|
division_id: string;
|
||||||
|
is_lead: boolean;
|
||||||
|
reports_to_employee_id: string | null;
|
||||||
valid_from: string;
|
valid_from: string;
|
||||||
/** Wer die Stelle nach der Berichtslinie führen wird. */
|
created_at: string;
|
||||||
managerName: string | null;
|
managerName: string | null;
|
||||||
orgLabel: string;
|
orgLabel: string;
|
||||||
/** Seit wann die Stelle unbesetzt ist: Ende der letzten Besetzung, sonst ihr Beginn. */
|
|
||||||
vacantSince: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// Shared by the Hire Wizard (position lookup) and the Positions & Bereiche page.
|
||||||
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
|
export async function loadOpenPositions(supabase: SupabaseClient<Database>): Promise<OpenPositionResolved[]> {
|
||||||
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe
|
const orgMaps = await loadOrgMaps(supabase);
|
||||||
* Regel wie in om_reporting_lines(), nur ohne Inhaber:in, für die sie gälte.
|
const { data: positions } = await supabase
|
||||||
*/
|
.from("positions")
|
||||||
function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): string | null {
|
.select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, valid_from, created_at")
|
||||||
if (!isChief) return orgUnitId;
|
.eq("status", "open")
|
||||||
return maps.units.get(orgUnitId)?.parent_id ?? null;
|
.order("created_at", { ascending: false });
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]> {
|
const managerIds = Array.from(
|
||||||
const asOf = todayIso();
|
new Set((positions ?? []).map((p) => p.reports_to_employee_id).filter((id): id is string => Boolean(id)))
|
||||||
|
|
||||||
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
|
|
||||||
.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", "j.title"])
|
|
||||||
.where("p.valid_from", "<=", asOf)
|
|
||||||
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
|
|
||||||
.where((eb) =>
|
|
||||||
eb.not(
|
|
||||||
eb.exists(
|
|
||||||
eb
|
|
||||||
.selectFrom("position_assignments as a")
|
|
||||||
.select("a.id")
|
|
||||||
.whereRef("a.position_id", "=", "p.id")
|
|
||||||
.where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)]))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy("p.position_number")
|
|
||||||
.execute(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (open.length === 0) return [];
|
|
||||||
|
|
||||||
const positionIds = open.map((p) => p.id);
|
|
||||||
|
|
||||||
// Zwei Nachschläge: seit wann die Stelle leer steht, und wer sie führen
|
|
||||||
// würde.
|
|
||||||
const [ended, chiefs] = await Promise.all([
|
|
||||||
tx
|
|
||||||
.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 [];
|
const { data: managers } = managerIds.length
|
||||||
return tx
|
? await supabase.from("employees").select("id, first_name, last_name").in("id", managerIds)
|
||||||
.selectFrom("om_positions as p")
|
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
||||||
.innerJoin("position_assignments as a", "a.position_id", "p.id")
|
const managerNameById = new Map((managers ?? []).map((m) => [m.id, `${m.first_name} ${m.last_name}`]));
|
||||||
.innerJoin("employees as e", "e.id", "a.employee_id")
|
|
||||||
.select(["p.org_unit_id", "e.first_name", "e.last_name"])
|
|
||||||
.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();
|
|
||||||
})(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const lastEndByPosition = new Map<string, string>();
|
return (positions ?? []).map((p) => ({
|
||||||
for (const e of ended) {
|
...p,
|
||||||
const prev = lastEndByPosition.get(e.position_id);
|
managerName: p.reports_to_employee_id ? (managerNameById.get(p.reports_to_employee_id) ?? null) : null,
|
||||||
if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to);
|
orgLabel: breadcrumbLabel(orgMaps, p.division_id, p.team_id),
|
||||||
}
|
}));
|
||||||
const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, `${c.first_name} ${c.last_name}`]));
|
|
||||||
|
|
||||||
return open.map((p) => {
|
|
||||||
const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
|
|
||||||
return {
|
|
||||||
id: p.id,
|
|
||||||
position_number: p.position_number,
|
|
||||||
title: p.title,
|
|
||||||
org_unit_id: p.org_unit_id,
|
|
||||||
is_chief: p.is_chief,
|
|
||||||
valid_from: p.valid_from,
|
|
||||||
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
|
|
||||||
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
|
|
||||||
vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import type { Tx } from "./db";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
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 { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
|
||||||
import type { EmploymentType, HistoryEventType } from "./supabase/types";
|
import { fetchAllRows } from "./supabase/query";
|
||||||
|
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
|
||||||
|
|
||||||
// Shared by the Berichte page and /api/export/* so they can never drift on
|
// 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
|
// what "the current view" means — same filters, same stichtag/event-window
|
||||||
// rules.
|
// rules.
|
||||||
export type ReportFilters = {
|
export type ReportFilters = {
|
||||||
/** Id einer Organisationseinheit; wirkt auf die Einheit *und alles darunter*. */
|
|
||||||
division?: string;
|
division?: string;
|
||||||
location?: string;
|
location?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -18,123 +16,69 @@ export type ReportFilters = {
|
|||||||
export type SnapshotFilters = ReportFilters & { asOf?: string };
|
export type SnapshotFilters = ReportFilters & { asOf?: string };
|
||||||
export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string };
|
export type EventFilters = { eventType?: HistoryEventType; division?: string; location?: string; from?: string; to?: string };
|
||||||
|
|
||||||
/**
|
export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promise<{
|
||||||
* Für jede Einheit vorberechnen, welcher Bereich, welche Abteilung und
|
|
||||||
* welches Team über ihr liegen. Ein Bericht gruppiert dann über einen
|
|
||||||
* Kartenzugriff statt über einen Aufstieg im Baum je Zeile.
|
|
||||||
*/
|
|
||||||
export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; name: string }[]): OrgLookups {
|
|
||||||
const divisionName = new Map<string, string>();
|
|
||||||
const departmentName = new Map<string, string>();
|
|
||||||
const teamName = new Map<string, string>();
|
|
||||||
|
|
||||||
for (const unit of orgMaps.unitList) {
|
|
||||||
for (const a of ancestorsOf(orgMaps, unit.id)) {
|
|
||||||
if (a.unit_type === "Bereich") divisionName.set(unit.id, a.name);
|
|
||||||
else if (a.unit_type === "Abteilung") departmentName.set(unit.id, a.name);
|
|
||||||
else if (a.unit_type === "Team") teamName.set(unit.id, a.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadOrgLookups(tx: Tx): Promise<{
|
|
||||||
lookups: OrgLookups;
|
lookups: OrgLookups;
|
||||||
orgMaps: OrgMaps;
|
|
||||||
divisions: { id: string; name: string }[];
|
divisions: { id: string; name: string }[];
|
||||||
locations: { id: string; name: string }[];
|
locations: { id: string; name: string }[];
|
||||||
}> {
|
}> {
|
||||||
const orgMaps = await loadOrgMaps(tx);
|
const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([
|
||||||
const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name }));
|
supabase.from("divisions").select("id, name").order("name"),
|
||||||
|
supabase.from("departments").select("id, name"),
|
||||||
|
supabase.from("teams").select("id, name, department_id"),
|
||||||
|
supabase.from("locations").select("id, name").order("name"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const departmentNameById = new Map((departments ?? []).map((d) => [d.id, d.name]));
|
||||||
return {
|
return {
|
||||||
lookups: lookupsFromOrgMaps(orgMaps, locations),
|
lookups: {
|
||||||
orgMaps,
|
divisionName: new Map((divisions ?? []).map((d) => [d.id, d.name])),
|
||||||
// Als Filter angeboten wird die oberste Ebene unter der Gesellschaft —
|
departmentNameByTeam: new Map((teams ?? []).map((t) => [t.id, departmentNameById.get(t.department_id) ?? "Unbekannt"])),
|
||||||
// das, was im Altmodell „Bereich" hiess. Der Filter greift auf den
|
teamName: new Map((teams ?? []).map((t) => [t.id, t.name])),
|
||||||
// ganzen Teilbaum.
|
locationName: new Map((locations ?? []).map((l) => [l.id, l.name])),
|
||||||
divisions: orgMaps.unitList.filter((u) => u.unit_type === "Bereich").map((u) => ({ id: u.id, name: u.name })),
|
},
|
||||||
locations,
|
divisions: divisions ?? [],
|
||||||
|
locations: locations ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SNAPSHOT_EMPLOYEE_COLUMNS = [
|
const SNAPSHOT_EMPLOYEE_COLUMNS =
|
||||||
"id",
|
"id, first_name, last_name, job_title, division_id, team_id, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level";
|
||||||
"first_name",
|
|
||||||
"last_name",
|
|
||||||
"job_title",
|
|
||||||
"location_id",
|
|
||||||
"employment_type",
|
|
||||||
"contract_type",
|
|
||||||
"entry_date",
|
|
||||||
"exit_date",
|
|
||||||
"weekly_hours",
|
|
||||||
"source",
|
|
||||||
"paygrade",
|
|
||||||
"birth_date",
|
|
||||||
"gender",
|
|
||||||
"karenz_start_date",
|
|
||||||
"karenz_return_date",
|
|
||||||
"worker_type",
|
|
||||||
"collective_agreement",
|
|
||||||
"work_days",
|
|
||||||
"is_betriebsrat",
|
|
||||||
"has_dienstwagen",
|
|
||||||
"is_laterale_fuehrung",
|
|
||||||
"is_c_level",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen —
|
// employee_id -> number of employee_dependents rows. Selects only the FK
|
||||||
// für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist.
|
// column (no dependent PII needed) since only per-employee counts feed the
|
||||||
export async function loadDependentsCounts(tx: Tx): Promise<Map<string, number>> {
|
// has_dependents/avg_dependents report dimensions; counted client-side
|
||||||
// Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle
|
// since the Supabase JS client has no `count(*) group by employee_id`
|
||||||
// Zeilen holt und sie selbst durchgeht.
|
// shorthand. Shared by the Bestand pivot and the full employees export.
|
||||||
const rows = await tx
|
export async function loadDependentsCounts(supabase: SupabaseClient<Database>): Promise<Map<string, number>> {
|
||||||
.selectFrom("employee_dependents")
|
const rows = await fetchAllRows(() => supabase.from("employee_dependents").select("employee_id").order("employee_id"));
|
||||||
.select(({ fn }) => ["employee_id", fn.countAll<string>().as("anzahl")])
|
const counts = new Map<string, number>();
|
||||||
.groupBy("employee_id")
|
for (const d of rows) counts.set(d.employee_id, (counts.get(d.employee_id) ?? 0) + 1);
|
||||||
.execute();
|
return counts;
|
||||||
return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)]));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst.
|
// Bestand zum Stichtag: reconstructs each employee's status as of `asOf`
|
||||||
export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): Promise<ReportEmployee[]> {
|
// (defaults to today) from entry/exit/Karenz dates — see deriveStatusAsOf.
|
||||||
|
// division/team/location still reflect the employee's *current* assignment.
|
||||||
|
export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> {
|
||||||
const asOf = filters.asOf || todayIso();
|
const asOf = filters.asOf || todayIso();
|
||||||
|
|
||||||
function snapshotQuery() {
|
function snapshotQuery() {
|
||||||
let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id");
|
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id");
|
||||||
if (filters.location) q = q.where("location_id", "=", filters.location);
|
if (filters.division) query = query.eq("division_id", filters.division);
|
||||||
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
|
if (filters.location) query = query.eq("location_id", filters.location);
|
||||||
return q;
|
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
||||||
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
|
const [data, dependentsCounts] = await Promise.all([fetchAllRows(snapshotQuery), loadDependentsCounts(supabase)]);
|
||||||
snapshotQuery().execute(),
|
|
||||||
loadDependentsCounts(tx),
|
|
||||||
loadPlacements(tx, { asOf }),
|
|
||||||
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst
|
const withDerivedStatus: ReportEmployee[] = data.map((e) => ({
|
||||||
// deren Abteilungen und Teams ein, sonst käme null heraus, weil unter dem
|
|
||||||
// Bereich selbst nur die Bereichsleitung sitzt.
|
|
||||||
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
|
||||||
|
|
||||||
const withDerivedStatus: ReportEmployee[] = [];
|
|
||||||
for (const e of data) {
|
|
||||||
const placement = placements.get(e.id);
|
|
||||||
// Zum Stichtag laufend? Sonst zählt die Person zwar noch im Bestand,
|
|
||||||
// sitzt aber auf keiner Planstelle mehr.
|
|
||||||
const orgUnitId = placement?.current ? placement.orgUnitId : null;
|
|
||||||
if (allowedUnits && (!orgUnitId || !allowedUnits.has(orgUnitId))) continue;
|
|
||||||
|
|
||||||
withDerivedStatus.push({
|
|
||||||
id: e.id,
|
id: e.id,
|
||||||
first_name: e.first_name,
|
first_name: e.first_name,
|
||||||
last_name: e.last_name,
|
last_name: e.last_name,
|
||||||
job_title: placement?.jobTitle ?? e.job_title,
|
job_title: e.job_title,
|
||||||
org_unit_id: orgUnitId,
|
division_id: e.division_id,
|
||||||
|
team_id: e.team_id,
|
||||||
location_id: e.location_id,
|
location_id: e.location_id,
|
||||||
status: deriveStatusAsOf(e, asOf),
|
status: deriveStatusAsOf(e, asOf),
|
||||||
employment_type: e.employment_type,
|
employment_type: e.employment_type,
|
||||||
@@ -154,75 +98,52 @@ export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): P
|
|||||||
is_laterale_fuehrung: e.is_laterale_fuehrung,
|
is_laterale_fuehrung: e.is_laterale_fuehrung,
|
||||||
is_c_level: e.is_c_level,
|
is_c_level: e.is_c_level,
|
||||||
dependents_count: dependentsCounts.get(e.id) ?? 0,
|
dependents_count: dependentsCounts.get(e.id) ?? 0,
|
||||||
});
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
const statuses = parseStatuses(filters.status);
|
const statuses = parseStatuses(filters.status);
|
||||||
return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number]));
|
return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie
|
// Ereignisse: employee_history has no division_id/team_id of its own, so
|
||||||
// kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte.
|
// this joins in the affected employee's *current* org placement (two plain
|
||||||
// Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem
|
// queries, merged in JS — the hand-written Database type has no relational
|
||||||
// Team stand, in das die Person nie versetzt worden war.
|
// embedding metadata for a single nested-select query).
|
||||||
//
|
//
|
||||||
// from/to: "" (unset) falls back to the current calendar year; the literal
|
// from/to: "" (unset) falls back to the current calendar year; the literal
|
||||||
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
|
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
|
||||||
// unbounded (e.g. "alle Ereignisse bis heute", no start date).
|
// unbounded (e.g. "alle Ereignisse bis heute", no start date).
|
||||||
export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise<ReportEvent[]> {
|
export async function loadEventHistory(supabase: SupabaseClient<Database>, filters: EventFilters): Promise<ReportEvent[]> {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`;
|
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`;
|
||||||
const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
|
const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
|
||||||
|
|
||||||
function historyQuery() {
|
function historyQuery() {
|
||||||
let q = tx
|
let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id");
|
||||||
.selectFrom("employee_history")
|
if (from) query = query.gte("event_date", from);
|
||||||
.select(["employee_id", "event_date", "event_type", "description"])
|
if (to) query = query.lte("event_date", to);
|
||||||
.orderBy("id");
|
if (filters.eventType) query = query.eq("event_type", filters.eventType);
|
||||||
if (from) q = q.where("event_date", ">=", from);
|
return query;
|
||||||
if (to) q = q.where("event_date", "<=", to);
|
|
||||||
if (filters.eventType) q = q.where("event_type", "=", filters.eventType);
|
|
||||||
return q;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [history, employees, assignments, orgMaps] = await Promise.all([
|
const [history, employees] = await Promise.all([
|
||||||
historyQuery().execute(),
|
fetchAllRows(historyQuery),
|
||||||
tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(),
|
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id").order("id")),
|
||||||
tx
|
|
||||||
.selectFrom("position_assignments as a")
|
|
||||||
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
|
||||||
.select(["a.employee_id", "a.valid_from", "a.valid_to", "p.org_unit_id"])
|
|
||||||
.orderBy("a.employee_id")
|
|
||||||
.execute(),
|
|
||||||
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
|
|
||||||
for (const a of assignments) {
|
|
||||||
const list = spans.get(a.employee_id) ?? [];
|
|
||||||
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.org_unit_id });
|
|
||||||
spans.set(a.employee_id, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowedUnits = orgMaps && filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
|
||||||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||||||
|
|
||||||
const events: ReportEvent[] = [];
|
const events: ReportEvent[] = [];
|
||||||
for (const h of history) {
|
for (const h of history) {
|
||||||
const emp = employeeById.get(h.employee_id);
|
const emp = employeeById.get(h.employee_id);
|
||||||
if (!emp) continue;
|
if (!emp) continue;
|
||||||
|
if (filters.division && emp.division_id !== filters.division) continue;
|
||||||
if (filters.location && emp.location_id !== filters.location) continue;
|
if (filters.location && emp.location_id !== filters.location) continue;
|
||||||
|
|
||||||
const unitId =
|
|
||||||
spans.get(h.employee_id)?.find((s2) => s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null;
|
|
||||||
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
|
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
employee_id: emp.id,
|
employee_id: emp.id,
|
||||||
first_name: emp.first_name,
|
first_name: emp.first_name,
|
||||||
last_name: emp.last_name,
|
last_name: emp.last_name,
|
||||||
job_title: emp.job_title,
|
job_title: emp.job_title,
|
||||||
org_unit_id: unitId,
|
division_id: emp.division_id,
|
||||||
|
team_id: emp.team_id,
|
||||||
location_id: emp.location_id,
|
location_id: emp.location_id,
|
||||||
event_date: h.event_date,
|
event_date: h.event_date,
|
||||||
event_type: h.event_type,
|
event_type: h.event_type,
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ export type ReportEmployee = {
|
|||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
job_title: string;
|
job_title: string;
|
||||||
/** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */
|
division_id: string;
|
||||||
org_unit_id: string | null;
|
team_id: string | null;
|
||||||
location_id: string;
|
location_id: string;
|
||||||
status: string;
|
status: string;
|
||||||
employment_type: string;
|
employment_type: string;
|
||||||
@@ -104,26 +104,20 @@ export type ReportEmployee = {
|
|||||||
dependents_count: number;
|
dependents_count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Alle drei sind über die *Einheit* der Planstelle geschlüsselt, nicht über
|
|
||||||
// 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.
|
|
||||||
export type OrgLookups = {
|
export type OrgLookups = {
|
||||||
divisionName: Map<string, string>;
|
divisionName: Map<string, string>;
|
||||||
departmentName: Map<string, string>;
|
departmentNameByTeam: Map<string, string>;
|
||||||
teamName: Map<string, string>;
|
teamName: Map<string, string>;
|
||||||
locationName: Map<string, string>;
|
locationName: Map<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reconstructs status as of any date from the columns that actually carry a
|
// Reconstructs status as of any date from the columns that actually carry a
|
||||||
// timeline (entry/exit/Karenz), rather than trusting `employees.status`,
|
// timeline (entry/exit/Karenz), rather than trusting `employees.status`,
|
||||||
// which only ever reflects *today*.
|
// which only ever reflects *today*. Division/team/location still reflect the
|
||||||
//
|
// employee's *current* assignment — the schema has no history of org-unit
|
||||||
// Die Einordnung in die Organisation wird zum selben Stichtag aufgelöst: seit
|
// changes over time, only free-text employee_history descriptions — so a
|
||||||
// dem OM-Modell ist position_assignments zeitabhängig, eine Auswertung
|
// stichtag report groups by today's org placement, not the placement as of
|
||||||
// gruppiert also nach der Einheit von damals. Vorher gab es diese Historie
|
// that date. Documented in the UI rather than silently wrong.
|
||||||
// nicht, und ein Stichtagsbericht gruppierte nach der heutigen Zuordnung —
|
|
||||||
// was in der Oberfläche vermerkt werden musste, statt still falsch zu sein.
|
|
||||||
export function deriveStatusAsOf(
|
export function deriveStatusAsOf(
|
||||||
e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null },
|
e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null },
|
||||||
asOf: string
|
asOf: string
|
||||||
@@ -143,11 +137,11 @@ function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: strin
|
|||||||
export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string {
|
export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string {
|
||||||
switch (dim) {
|
switch (dim) {
|
||||||
case "division":
|
case "division":
|
||||||
return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–";
|
return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
|
||||||
case "department":
|
case "department":
|
||||||
return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–";
|
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–";
|
||||||
case "team":
|
case "team":
|
||||||
return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–";
|
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–";
|
||||||
case "location":
|
case "location":
|
||||||
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
|
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
|
||||||
case "status":
|
case "status":
|
||||||
@@ -262,7 +256,7 @@ export function aggregateReport(
|
|||||||
id: e.id,
|
id: e.id,
|
||||||
name: `${e.first_name} ${e.last_name}`,
|
name: `${e.first_name} ${e.last_name}`,
|
||||||
title: e.job_title,
|
title: e.job_title,
|
||||||
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–",
|
team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–",
|
||||||
entry_date: e.entry_date,
|
entry_date: e.entry_date,
|
||||||
}));
|
}));
|
||||||
const row: ReportRow = { key, value, count: rowsForGroup.length, people };
|
const row: ReportRow = { key, value, count: rowsForGroup.length, people };
|
||||||
@@ -351,8 +345,8 @@ export type ReportEvent = {
|
|||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
job_title: string;
|
job_title: string;
|
||||||
/** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */
|
division_id: string;
|
||||||
org_unit_id: string | null;
|
team_id: string | null;
|
||||||
location_id: string;
|
location_id: string;
|
||||||
event_date: string;
|
event_date: string;
|
||||||
event_type: HistoryEventType;
|
event_type: HistoryEventType;
|
||||||
@@ -364,11 +358,11 @@ function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: Org
|
|||||||
case "event_type":
|
case "event_type":
|
||||||
return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type;
|
return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type;
|
||||||
case "division":
|
case "division":
|
||||||
return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–";
|
return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
|
||||||
case "department":
|
case "department":
|
||||||
return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–";
|
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–";
|
||||||
case "team":
|
case "team":
|
||||||
return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–";
|
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–";
|
||||||
case "location":
|
case "location":
|
||||||
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
|
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
|
||||||
case "event_year":
|
case "event_year":
|
||||||
@@ -400,7 +394,7 @@ export function aggregateEvents(
|
|||||||
id: e.employee_id,
|
id: e.employee_id,
|
||||||
name: `${e.first_name} ${e.last_name}`,
|
name: `${e.first_name} ${e.last_name}`,
|
||||||
title: e.description,
|
title: e.description,
|
||||||
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–",
|
team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–",
|
||||||
entry_date: e.event_date,
|
entry_date: e.event_date,
|
||||||
}));
|
}));
|
||||||
const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people };
|
const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people };
|
||||||
|
|||||||
23
lib/supabase/admin.ts
Normal file
23
lib/supabase/admin.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import type { Database } from "./types";
|
||||||
|
|
||||||
|
// Service-role client: bypasses RLS entirely. Server-only — never import this
|
||||||
|
// from a Client Component or anything bundled for the browser. The
|
||||||
|
// "server-only" import makes an accidental client-side import a build error
|
||||||
|
// instead of a runtime one.
|
||||||
|
export function createAdminClient() {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl) {
|
||||||
|
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
||||||
|
}
|
||||||
|
if (!serviceRoleKey) {
|
||||||
|
throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSupabaseClient<Database>(supabaseUrl, serviceRoleKey, {
|
||||||
|
auth: { autoRefreshToken: false, persistSession: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
21
lib/supabase/auth.ts
Normal file
21
lib/supabase/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { Database } from "./types";
|
||||||
|
|
||||||
|
// Route Handlers under /api/export/* are outside the App Router layout tree,
|
||||||
|
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
|
||||||
|
// re-establish that the caller is an active HR user itself. RLS is still the
|
||||||
|
// real boundary (an unauthorized session simply reads nothing); this exists
|
||||||
|
// so those routes answer 401/403 instead of handing back an empty workbook.
|
||||||
|
export async function requireHrUser(supabase: SupabaseClient<Database>): Promise<NextResponse | null> {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
|
||||||
|
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
|
||||||
|
if (profile?.role !== "hr" || profile.is_active !== true) {
|
||||||
|
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
19
lib/supabase/client.ts
Normal file
19
lib/supabase/client.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
import type { Database } from "./types";
|
||||||
|
|
||||||
|
// For use in Client Components that need interactivity (filters, live
|
||||||
|
// hints, etc). Server Components/Actions should use lib/supabase/server.ts.
|
||||||
|
// Only ever reads NEXT_PUBLIC_* vars — this file is bundled for the browser.
|
||||||
|
export function createClient() {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl) {
|
||||||
|
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
||||||
|
}
|
||||||
|
if (!supabaseAnonKey) {
|
||||||
|
throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
return createBrowserClient<Database>(supabaseUrl, supabaseAnonKey);
|
||||||
|
}
|
||||||
33
lib/supabase/query.ts
Normal file
33
lib/supabase/query.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// PostgREST's .or() filter syntax treats "," "(" and ")" as structural
|
||||||
|
// delimiters between conditions. A raw user-supplied search term containing
|
||||||
|
// them (e.g. from a search box or ?q= param) can break out of the intended
|
||||||
|
// column conditions and append arbitrary extra filters to the query. Strip
|
||||||
|
// them before interpolating — harmless for real name/title searches, which
|
||||||
|
// never legitimately contain them.
|
||||||
|
export function sanitizeIlikeTerm(term: string): string {
|
||||||
|
return term.replace(/[,()]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostgREST caps every response at db.max_rows (1000, see
|
||||||
|
// supabase/config.toml) and does so *silently* — a query over ~800 employees
|
||||||
|
// or the employee_history log just stops returning rows, and a report or
|
||||||
|
// export built from it is quietly wrong rather than failing. Anything that
|
||||||
|
// aggregates a whole table has to page explicitly; anything that renders a
|
||||||
|
// bounded list (an employee page, the audit log) uses .range() directly and
|
||||||
|
// does not need this.
|
||||||
|
const PAGE_SIZE = 1000;
|
||||||
|
|
||||||
|
type PagedQuery<Row> = {
|
||||||
|
range: (from: number, to: number) => PromiseLike<{ data: Row[] | null; error: unknown }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchAllRows<Row>(buildQuery: () => PagedQuery<Row>): Promise<Row[]> {
|
||||||
|
const rows: Row[] = [];
|
||||||
|
for (let page = 0; ; page++) {
|
||||||
|
const { data, error } = await buildQuery().range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1);
|
||||||
|
if (error || !data) break;
|
||||||
|
rows.push(...data);
|
||||||
|
if (data.length < PAGE_SIZE) break;
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
40
lib/supabase/server.ts
Normal file
40
lib/supabase/server.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import type { Database } from "./types";
|
||||||
|
|
||||||
|
// For use in Server Components and Server Actions. Respects the signed-in
|
||||||
|
// user's session, so all reads/writes go through RLS as that user. Uses only
|
||||||
|
// the anon key (never the service role key) — the user's own session cookie
|
||||||
|
// is what determines access, via RLS.
|
||||||
|
export async function createClient() {
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
if (!supabaseUrl) {
|
||||||
|
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
||||||
|
}
|
||||||
|
if (!supabaseAnonKey) {
|
||||||
|
throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
return createServerClient<Database>(supabaseUrl, supabaseAnonKey, {
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Called from a Server Component during render — safe to ignore
|
||||||
|
// because proxy.ts refreshes the session cookie on every request.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -12,24 +12,12 @@ export type GenderType = "m" | "w";
|
|||||||
export type WorkerType = "Angestellte:r" | "Arbeiter:in";
|
export type WorkerType = "Angestellte:r" | "Arbeiter:in";
|
||||||
export type CollectiveAgreement = "Handel" | "Süßwaren";
|
export type CollectiveAgreement = "Handel" | "Süßwaren";
|
||||||
export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
|
export type Weekday = "Mo" | "Di" | "Mi" | "Do" | "Fr" | "Sa" | "So";
|
||||||
|
|
||||||
/**
|
|
||||||
* Eine einzelne Feldänderung im Protokoll.
|
|
||||||
*
|
|
||||||
* `vorher` und `nachher` sind bewusst Text: die Datenbank stellt jeden Typ
|
|
||||||
* über ::text dar, damit ein Datum, eine Zahl und eine Liste von Arbeitstagen
|
|
||||||
* in derselben Spalte nebeneinander stehen können. Für die Anzeige reicht
|
|
||||||
* das; gerechnet wird damit nicht.
|
|
||||||
*/
|
|
||||||
export type AuditChange = { feld: string; vorher: string | null; nachher: string | null };
|
|
||||||
export type RelationshipType = "Ehepartner:in" | "Lebenspartner:in" | "Kind" | "Sonstige";
|
export type RelationshipType = "Ehepartner:in" | "Lebenspartner:in" | "Kind" | "Sonstige";
|
||||||
export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung";
|
export type NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung";
|
||||||
// Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a
|
// Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a
|
||||||
// union (not a string literal) so a future hr_admin/hr_user split, if ever
|
// union (not a string literal) so a future hr_admin/hr_user split, if ever
|
||||||
// technically required, is a type-level addition, not a rewrite.
|
// technically required, is a type-level addition, not a rewrite.
|
||||||
export type ProfileRole = "hr";
|
export type ProfileRole = "hr";
|
||||||
/** Etikett einer Organisationseinheit; die Struktur steckt in parent_id. */
|
|
||||||
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
|
|
||||||
export type HistoryEventType =
|
export type HistoryEventType =
|
||||||
| "Eintritt"
|
| "Eintritt"
|
||||||
| "Beförderung"
|
| "Beförderung"
|
||||||
@@ -42,6 +30,8 @@ export type HistoryEventType =
|
|||||||
| "Reorganisation"
|
| "Reorganisation"
|
||||||
| "Gehaltsanpassung"
|
| "Gehaltsanpassung"
|
||||||
| "Rückkehr";
|
| "Rückkehr";
|
||||||
|
export type PositionStatus = "open" | "filled";
|
||||||
|
export type ReorgMoveKind = "emp" | "team" | "abt" | "dept";
|
||||||
export type PendingChangeType =
|
export type PendingChangeType =
|
||||||
| "transfer"
|
| "transfer"
|
||||||
| "promotion"
|
| "promotion"
|
||||||
@@ -59,6 +49,21 @@ type NoRelationships = { Relationships: [] };
|
|||||||
export type Database = {
|
export type Database = {
|
||||||
public: {
|
public: {
|
||||||
Tables: {
|
Tables: {
|
||||||
|
divisions: NoRelationships & {
|
||||||
|
Row: { id: string; org_number: string; name: string };
|
||||||
|
Insert: { id?: string; org_number: string; name: string };
|
||||||
|
Update: Partial<{ id: string; org_number: string; name: string }>;
|
||||||
|
};
|
||||||
|
departments: NoRelationships & {
|
||||||
|
Row: { id: string; org_number: string; name: string; division_id: string };
|
||||||
|
Insert: { id?: string; org_number: string; name: string; division_id: string };
|
||||||
|
Update: Partial<{ id: string; org_number: string; name: string; division_id: string }>;
|
||||||
|
};
|
||||||
|
teams: NoRelationships & {
|
||||||
|
Row: { id: string; org_number: string; name: string; department_id: string };
|
||||||
|
Insert: { id?: string; org_number: string; name: string; department_id: string };
|
||||||
|
Update: Partial<{ id: string; org_number: string; name: string; department_id: string }>;
|
||||||
|
};
|
||||||
locations: NoRelationships & {
|
locations: NoRelationships & {
|
||||||
Row: { id: string; name: string; country: string };
|
Row: { id: string; name: string; country: string };
|
||||||
Insert: { id?: string; name: string; country: string };
|
Insert: { id?: string; name: string; country: string };
|
||||||
@@ -112,8 +117,13 @@ export type Database = {
|
|||||||
address_country: string | null;
|
address_country: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
|
team_id: string | null;
|
||||||
|
division_id: string;
|
||||||
job_title: string;
|
job_title: string;
|
||||||
location_id: string;
|
location_id: string;
|
||||||
|
manager_id: string | null;
|
||||||
|
org_level: number;
|
||||||
|
is_lead: boolean;
|
||||||
employment_type: EmploymentType;
|
employment_type: EmploymentType;
|
||||||
weekly_hours: number;
|
weekly_hours: number;
|
||||||
/** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */
|
/** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */
|
||||||
@@ -156,8 +166,13 @@ export type Database = {
|
|||||||
address_country?: string | null;
|
address_country?: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
|
team_id?: string | null;
|
||||||
|
division_id?: string;
|
||||||
job_title: string;
|
job_title: string;
|
||||||
location_id: string;
|
location_id: string;
|
||||||
|
manager_id?: string | null;
|
||||||
|
org_level?: number;
|
||||||
|
is_lead?: boolean;
|
||||||
employment_type?: EmploymentType;
|
employment_type?: EmploymentType;
|
||||||
weekly_hours?: number;
|
weekly_hours?: number;
|
||||||
contract_type?: ContractType;
|
contract_type?: ContractType;
|
||||||
@@ -193,6 +208,7 @@ export type Database = {
|
|||||||
event_date: string;
|
event_date: string;
|
||||||
event_type: HistoryEventType;
|
event_type: HistoryEventType;
|
||||||
description: string;
|
description: string;
|
||||||
|
reorg_scenario_id: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
@@ -201,6 +217,7 @@ export type Database = {
|
|||||||
event_date: string;
|
event_date: string;
|
||||||
event_type: HistoryEventType;
|
event_type: HistoryEventType;
|
||||||
description: string;
|
description: string;
|
||||||
|
reorg_scenario_id?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
};
|
};
|
||||||
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
|
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
|
||||||
@@ -257,6 +274,37 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
Update: Partial<Database["public"]["Tables"]["employee_notes"]["Insert"]>;
|
Update: Partial<Database["public"]["Tables"]["employee_notes"]["Insert"]>;
|
||||||
};
|
};
|
||||||
|
positions: NoRelationships & {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
position_number: string;
|
||||||
|
title: string;
|
||||||
|
team_id: string;
|
||||||
|
division_id: string;
|
||||||
|
is_lead: boolean;
|
||||||
|
reports_to_employee_id: string | null;
|
||||||
|
status: PositionStatus;
|
||||||
|
valid_from: string;
|
||||||
|
created_at: string;
|
||||||
|
filled_at: string | null;
|
||||||
|
filled_by_employee_id: string | null;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
position_number?: string;
|
||||||
|
title: string;
|
||||||
|
team_id: string;
|
||||||
|
division_id?: string;
|
||||||
|
is_lead?: boolean;
|
||||||
|
reports_to_employee_id?: string | null;
|
||||||
|
status?: PositionStatus;
|
||||||
|
valid_from?: string;
|
||||||
|
created_at?: string;
|
||||||
|
filled_at?: string | null;
|
||||||
|
filled_by_employee_id?: string | null;
|
||||||
|
};
|
||||||
|
Update: Partial<Database["public"]["Tables"]["positions"]["Insert"]>;
|
||||||
|
};
|
||||||
hire_drafts: NoRelationships & {
|
hire_drafts: NoRelationships & {
|
||||||
Row: { id: string; created_by: string | null; step: number; payload: Record<string, unknown>; updated_at: string };
|
Row: { id: string; created_by: string | null; step: number; payload: Record<string, unknown>; updated_at: string };
|
||||||
Insert: { id?: string; created_by?: string | null; step?: number; payload: Record<string, unknown>; updated_at?: string };
|
Insert: { id?: string; created_by?: string | null; step?: number; payload: Record<string, unknown>; updated_at?: string };
|
||||||
@@ -277,13 +325,6 @@ export type Database = {
|
|||||||
target_label: string;
|
target_label: string;
|
||||||
target_employee_id: string | null;
|
target_employee_id: string | null;
|
||||||
details: string | null;
|
details: string | null;
|
||||||
/**
|
|
||||||
* Feldweise Änderungen. Null bei Einträgen aus der Zeit vor
|
|
||||||
* 20260803140000_audit_changes_detail.sql — dort wurden nur die
|
|
||||||
* Feldnamen behalten, nicht die Werte, und das lässt sich nicht
|
|
||||||
* nachliefern.
|
|
||||||
*/
|
|
||||||
changes: AuditChange[] | null;
|
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -294,10 +335,37 @@ export type Database = {
|
|||||||
target_label: string;
|
target_label: string;
|
||||||
target_employee_id?: string | null;
|
target_employee_id?: string | null;
|
||||||
details?: string | null;
|
details?: string | null;
|
||||||
changes?: AuditChange[] | null;
|
|
||||||
};
|
};
|
||||||
Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>;
|
Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>;
|
||||||
};
|
};
|
||||||
|
reorg_scenarios: NoRelationships & {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
effective_date: string;
|
||||||
|
created_by: string | null;
|
||||||
|
applied: boolean;
|
||||||
|
applied_at: string | null;
|
||||||
|
undo_snapshot: Record<string, unknown> | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
effective_date: string;
|
||||||
|
created_by?: string | null;
|
||||||
|
applied?: boolean;
|
||||||
|
applied_at?: string | null;
|
||||||
|
undo_snapshot?: Record<string, unknown> | null;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
Update: Partial<Database["public"]["Tables"]["reorg_scenarios"]["Insert"]>;
|
||||||
|
};
|
||||||
|
reorg_moves: NoRelationships & {
|
||||||
|
Row: { id: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
|
||||||
|
Insert: { id?: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
|
||||||
|
Update: Partial<Database["public"]["Tables"]["reorg_moves"]["Insert"]>;
|
||||||
|
};
|
||||||
pending_org_changes: NoRelationships & {
|
pending_org_changes: NoRelationships & {
|
||||||
Row: {
|
Row: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -305,6 +373,7 @@ export type Database = {
|
|||||||
change_type: PendingChangeType;
|
change_type: PendingChangeType;
|
||||||
effective_date: string;
|
effective_date: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
|
reorg_scenario_id: string | null;
|
||||||
status: PendingChangeStatus;
|
status: PendingChangeStatus;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -316,6 +385,7 @@ export type Database = {
|
|||||||
change_type: PendingChangeType;
|
change_type: PendingChangeType;
|
||||||
effective_date: string;
|
effective_date: string;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
|
reorg_scenario_id?: string | null;
|
||||||
status?: PendingChangeStatus;
|
status?: PendingChangeStatus;
|
||||||
created_by?: string | null;
|
created_by?: string | null;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
@@ -323,120 +393,24 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
|
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
|
||||||
};
|
};
|
||||||
// ── SAP-OM-Modell ──────────────────────────────────────────
|
// Written exclusively by trg_track_employee_assignment; RLS grants HR
|
||||||
// O: rekursiv über parent_id, unit_type ist nur ein Etikett.
|
// read access only, hence no Insert/Update shapes worth modelling.
|
||||||
org_units: {
|
employee_assignments: NoRelationships & {
|
||||||
Relationships: [
|
|
||||||
{
|
|
||||||
foreignKeyName: "org_units_parent_id_fkey";
|
|
||||||
columns: ["parent_id"];
|
|
||||||
referencedRelation: "org_units";
|
|
||||||
referencedColumns: ["id"];
|
|
||||||
},
|
|
||||||
];
|
|
||||||
Row: {
|
Row: {
|
||||||
id: string;
|
id: string;
|
||||||
org_number: string;
|
|
||||||
name: string;
|
|
||||||
parent_id: string | null;
|
|
||||||
unit_type: OrgUnitType;
|
|
||||||
valid_from: string;
|
|
||||||
valid_to: string | null;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
Insert: {
|
|
||||||
id?: string;
|
|
||||||
org_number: string;
|
|
||||||
name: string;
|
|
||||||
parent_id?: string | null;
|
|
||||||
unit_type: OrgUnitType;
|
|
||||||
valid_from?: string;
|
|
||||||
valid_to?: string | null;
|
|
||||||
created_at?: string;
|
|
||||||
};
|
|
||||||
Update: Partial<Database["public"]["Tables"]["org_units"]["Insert"]>;
|
|
||||||
};
|
|
||||||
// C: Katalog der Tätigkeiten.
|
|
||||||
jobs: NoRelationships & {
|
|
||||||
Row: { id: string; code: string; title: string; created_at: string };
|
|
||||||
Insert: { id?: string; code: string; title: string; created_at?: string };
|
|
||||||
Update: Partial<Database["public"]["Tables"]["jobs"]["Insert"]>;
|
|
||||||
};
|
|
||||||
// S: Planstelle. Der Name om_positions stammt aus der Zeit, in der die
|
|
||||||
// alte positions-Tabelle noch danebenstand; sie ist inzwischen weg.
|
|
||||||
om_positions: {
|
|
||||||
Relationships: [
|
|
||||||
{
|
|
||||||
foreignKeyName: "om_positions_org_unit_id_fkey";
|
|
||||||
columns: ["org_unit_id"];
|
|
||||||
referencedRelation: "org_units";
|
|
||||||
referencedColumns: ["id"];
|
|
||||||
},
|
|
||||||
{
|
|
||||||
foreignKeyName: "om_positions_job_id_fkey";
|
|
||||||
columns: ["job_id"];
|
|
||||||
referencedRelation: "jobs";
|
|
||||||
referencedColumns: ["id"];
|
|
||||||
},
|
|
||||||
];
|
|
||||||
Row: {
|
|
||||||
id: string;
|
|
||||||
position_number: string;
|
|
||||||
org_unit_id: string;
|
|
||||||
job_id: string;
|
|
||||||
is_chief: boolean;
|
|
||||||
valid_from: string;
|
|
||||||
valid_to: string | null;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
Insert: {
|
|
||||||
id?: string;
|
|
||||||
position_number: string;
|
|
||||||
org_unit_id: string;
|
|
||||||
job_id: string;
|
|
||||||
is_chief?: boolean;
|
|
||||||
valid_from?: string;
|
|
||||||
valid_to?: string | null;
|
|
||||||
created_at?: string;
|
|
||||||
};
|
|
||||||
Update: Partial<Database["public"]["Tables"]["om_positions"]["Insert"]>;
|
|
||||||
};
|
|
||||||
// A008: Person besetzt Planstelle, zeitabhängig.
|
|
||||||
position_assignments: {
|
|
||||||
Row: {
|
|
||||||
id: string;
|
|
||||||
position_id: string;
|
|
||||||
employee_id: string;
|
employee_id: string;
|
||||||
|
manager_id: string | null;
|
||||||
|
team_id: string | null;
|
||||||
|
division_id: string;
|
||||||
|
job_title: string;
|
||||||
|
is_lead: boolean;
|
||||||
|
org_level: number;
|
||||||
valid_from: string;
|
valid_from: string;
|
||||||
valid_to: string | null;
|
valid_to: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
Insert: {
|
Insert: never;
|
||||||
id?: string;
|
Update: never;
|
||||||
position_id: string;
|
|
||||||
employee_id: string;
|
|
||||||
valid_from: string;
|
|
||||||
valid_to?: string | null;
|
|
||||||
created_at?: string;
|
|
||||||
};
|
|
||||||
Update: Partial<Database["public"]["Tables"]["position_assignments"]["Insert"]>;
|
|
||||||
// Beide Richtungen: über die Planstelle hängt die Verortung in der
|
|
||||||
// Organisation, über die Person die Verortung in der Akte. Die
|
|
||||||
// Einbettung erspart an einem Dutzend Stellen eine zweite Abfrage.
|
|
||||||
Relationships: [
|
|
||||||
{
|
|
||||||
foreignKeyName: "position_assignments_position_id_fkey";
|
|
||||||
columns: ["position_id"];
|
|
||||||
referencedRelation: "om_positions";
|
|
||||||
referencedColumns: ["id"];
|
|
||||||
},
|
|
||||||
{
|
|
||||||
foreignKeyName: "position_assignments_employee_id_fkey";
|
|
||||||
columns: ["employee_id"];
|
|
||||||
referencedRelation: "employees";
|
|
||||||
referencedColumns: ["id"];
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
Views: Record<string, never>;
|
Views: Record<string, never>;
|
||||||
@@ -454,23 +428,13 @@ export type Database = {
|
|||||||
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
|
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
|
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||||
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
|
complete_employee_note: { 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 };
|
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||||
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
|
staff_position_internally: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
is_valid_svnr: { Args: { p_svnr: string; p_birth_date?: string | null }; Returns: boolean };
|
||||||
|
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||||
|
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
||||||
om_reporting_lines: {
|
|
||||||
Args: { p_as_of?: string };
|
|
||||||
Returns: {
|
|
||||||
employee_id: string;
|
|
||||||
position_id: string;
|
|
||||||
org_unit_id: string;
|
|
||||||
is_chief: boolean;
|
|
||||||
formal_manager_id: string | null;
|
|
||||||
acting_manager_id: string | null;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,28 +2,25 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
// Report-only rather than enforcing, deliberately: the policy is derived from
|
// Report-only rather than enforcing, deliberately: the policy is derived from
|
||||||
// what this app is known to load — its own bundle, the self-hosted Nunito
|
// what this app is known to load — its own bundle, the self-hosted Nunito
|
||||||
// files from next/font, and nothing else — but an unenforced policy that logs
|
// files from next/font, and the Supabase project from
|
||||||
// violations is worth more than a guessed one that blanks the app for every
|
// NEXT_PUBLIC_SUPABASE_URL — but an unenforced policy that logs violations is
|
||||||
// HR user. Promote it to `Content-Security-Policy` once the reports come back
|
// worth more than a guessed one that blanks the app for every HR user.
|
||||||
// clean.
|
// Promote it to `Content-Security-Policy` once the reports come back clean.
|
||||||
//
|
//
|
||||||
// 'unsafe-inline' on script-src is not laziness: Next.js inlines its
|
// 'unsafe-inline' on script-src is not laziness: Next.js inlines its
|
||||||
// bootstrap and hydration payload as inline <script> tags, so a nonce-based
|
// bootstrap and hydration payload as inline <script> tags, so a nonce-based
|
||||||
// policy means threading a per-request nonce through proxy.ts — a separate
|
// policy means threading a per-request nonce through proxy.ts — a separate
|
||||||
// change, and the reason this starts in report-only.
|
// change, and the reason this starts in report-only.
|
||||||
function contentSecurityPolicy(): string {
|
function contentSecurityPolicy(): string {
|
||||||
// Die Anmeldung schickt ein Formular an /api/auth/signin/…, und von dort
|
let supabaseOrigin = "";
|
||||||
// geht es per Weiterleitung zum Anmeldeserver. `form-action` gilt auch für
|
|
||||||
// die Weiterleitungen nach einem Formularversand — steht der Aussteller
|
|
||||||
// nicht darin, bricht der Browser die Anmeldung ab. Genau hier wäre die
|
|
||||||
// Nur-Bericht-Fassung später eine böse Überraschung.
|
|
||||||
let issuerOrigin = "";
|
|
||||||
try {
|
try {
|
||||||
issuerOrigin = new URL(process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER ?? "").origin;
|
supabaseOrigin = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL ?? "").origin;
|
||||||
} catch {
|
} catch {
|
||||||
issuerOrigin = "";
|
supabaseOrigin = "";
|
||||||
}
|
}
|
||||||
const formAction = ["'self'", issuerOrigin].filter(Boolean).join(" ");
|
// Supabase Auth refreshes tokens over https and Realtime opens a websocket
|
||||||
|
// against the same host.
|
||||||
|
const connect = ["'self'", supabaseOrigin, supabaseOrigin.replace(/^http/, "ws")].filter(Boolean).join(" ");
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
@@ -31,13 +28,10 @@ function contentSecurityPolicy(): string {
|
|||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data: blob:",
|
"img-src 'self' data: blob:",
|
||||||
"font-src 'self'",
|
"font-src 'self'",
|
||||||
// Die Anwendung spricht im Browser mit niemandem ausser sich selbst: die
|
`connect-src ${connect}`,
|
||||||
// Datenbank erreicht nur der Server, und seit die API-Schicht weg ist,
|
|
||||||
// gibt es keine Gegenstelle mehr, die von aussen angesprochen würde.
|
|
||||||
"connect-src 'self'",
|
|
||||||
"frame-ancestors 'self'",
|
"frame-ancestors 'self'",
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
`form-action ${formAction}`,
|
"form-action 'self'",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
].join("; ");
|
].join("; ");
|
||||||
}
|
}
|
||||||
|
|||||||
268
package-lock.json
generated
268
package-lock.json
generated
@@ -13,11 +13,8 @@
|
|||||||
"@supabase/supabase-js": "^2.110.8",
|
"@supabase/supabase-js": "^2.110.8",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"kysely": "^0.29.4",
|
|
||||||
"lucide-react": "^1.26.0",
|
"lucide-react": "^1.26.0",
|
||||||
"next": "^16.2.11",
|
"next": "^16.2.11",
|
||||||
"next-auth": "^5.0.0-beta.32",
|
|
||||||
"pg": "^8.22.0",
|
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"server-only": "^0.0.1"
|
"server-only": "^0.0.1"
|
||||||
@@ -28,7 +25,6 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
@@ -40,9 +36,6 @@
|
|||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22 <25"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
@@ -116,35 +109,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@auth/core": {
|
|
||||||
"version": "0.41.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.3.tgz",
|
|
||||||
"integrity": "sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"@panva/hkdf": "^1.2.1",
|
|
||||||
"jose": "^6.0.6",
|
|
||||||
"oauth4webapi": "^3.3.0",
|
|
||||||
"preact": "10.24.3",
|
|
||||||
"preact-render-to-string": "6.5.11"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@simplewebauthn/browser": "^9.0.1",
|
|
||||||
"@simplewebauthn/server": "^9.0.2",
|
|
||||||
"nodemailer": "^7.0.7 || ^8.0.5"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@simplewebauthn/browser": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@simplewebauthn/server": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"nodemailer": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.7",
|
"version": "7.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||||
@@ -1804,15 +1768,6 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@panva/hkdf": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/panva"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||||
@@ -2876,18 +2831,6 @@
|
|||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/pg": {
|
|
||||||
"version": "8.20.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
|
||||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*",
|
|
||||||
"pg-protocol": "*",
|
|
||||||
"pg-types": "^2.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.17",
|
"version": "19.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
@@ -6949,6 +6892,7 @@
|
|||||||
"version": "6.2.3",
|
"version": "6.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
@@ -7169,15 +7113,6 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/kysely": {
|
|
||||||
"version": "0.29.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.29.4.tgz",
|
|
||||||
"integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=22.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/language-subtag-registry": {
|
"node_modules/language-subtag-registry": {
|
||||||
"version": "0.3.23",
|
"version": "0.3.23",
|
||||||
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
|
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
|
||||||
@@ -7933,33 +7868,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/next-auth": {
|
|
||||||
"version": "5.0.0-beta.32",
|
|
||||||
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.32.tgz",
|
|
||||||
"integrity": "sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"@auth/core": "0.41.3"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@simplewebauthn/browser": "^9.0.1",
|
|
||||||
"@simplewebauthn/server": "^9.0.2",
|
|
||||||
"next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
|
|
||||||
"nodemailer": "^7.0.7 || ^8.0.5",
|
|
||||||
"react": "^18.2.0 || ^19.0.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@simplewebauthn/browser": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@simplewebauthn/server": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"nodemailer": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/node-exports-info": {
|
"node_modules/node-exports-info": {
|
||||||
"version": "1.6.2",
|
"version": "1.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
|
||||||
@@ -7998,15 +7906,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/oauth4webapi": {
|
|
||||||
"version": "3.8.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
|
|
||||||
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/panva"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-assign": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -8296,95 +8195,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/pg": {
|
|
||||||
"version": "8.22.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
|
|
||||||
"integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pg-connection-string": "^2.14.0",
|
|
||||||
"pg-pool": "^3.14.0",
|
|
||||||
"pg-protocol": "^1.15.0",
|
|
||||||
"pg-types": "2.2.0",
|
|
||||||
"pgpass": "1.0.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 16.0.0"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"pg-cloudflare": "^1.4.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"pg-native": ">=3.0.1"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"pg-native": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-cloudflare": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/pg-connection-string": {
|
|
||||||
"version": "2.14.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
|
|
||||||
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/pg-int8": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-pool": {
|
|
||||||
"version": "3.14.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
|
||||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"pg": ">=8.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pg-protocol": {
|
|
||||||
"version": "1.15.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
|
|
||||||
"integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/pg-types": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pg-int8": "1.0.1",
|
|
||||||
"postgres-array": "~2.0.0",
|
|
||||||
"postgres-bytea": "~1.0.0",
|
|
||||||
"postgres-date": "~1.0.4",
|
|
||||||
"postgres-interval": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pgpass": {
|
|
||||||
"version": "1.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
|
||||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"split2": "^4.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -8442,64 +8252,6 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postgres-array": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-bytea": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-date": {
|
|
||||||
"version": "1.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
|
||||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postgres-interval": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"xtend": "^4.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/preact": {
|
|
||||||
"version": "10.24.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
|
|
||||||
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/preact"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/preact-render-to-string": {
|
|
||||||
"version": "6.5.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
|
|
||||||
"integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"preact": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -9206,15 +8958,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/split2": {
|
|
||||||
"version": "4.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
|
||||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -10442,15 +10185,6 @@
|
|||||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/xtend": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@@ -24,11 +24,8 @@
|
|||||||
"@supabase/supabase-js": "^2.110.8",
|
"@supabase/supabase-js": "^2.110.8",
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"kysely": "^0.29.4",
|
|
||||||
"lucide-react": "^1.26.0",
|
"lucide-react": "^1.26.0",
|
||||||
"next": "^16.2.11",
|
"next": "^16.2.11",
|
||||||
"next-auth": "^5.0.0-beta.32",
|
|
||||||
"pg": "^8.22.0",
|
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"server-only": "^0.0.1"
|
"server-only": "^0.0.1"
|
||||||
@@ -39,7 +36,6 @@
|
|||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitest/coverage-v8": "^4.1.10",
|
"@vitest/coverage-v8": "^4.1.10",
|
||||||
|
|||||||
111
proxy.ts
111
proxy.ts
@@ -1,81 +1,68 @@
|
|||||||
import NextAuth from "next-auth";
|
import { createServerClient } from "@supabase/ssr";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { authConfig } from "@/lib/auth/config";
|
|
||||||
|
|
||||||
// Next.js 16 renamed Middleware to Proxy (same mechanism, new filename and
|
// Next.js 16 renamed Middleware to Proxy (same mechanism, new filename/export).
|
||||||
// export). Das hier ist die vorderste Tür: wer keine Sitzung hat, landet auf
|
// This is the app's single entry-point gate (spec
|
||||||
// /login statt in der Anwendung.
|
// §2.2): unauthenticated users are sent to /login, and — this is the part
|
||||||
//
|
// that used to be missing — authenticated users who are NOT an active,
|
||||||
// Gebaut wird dafür eine *zweite*, absichtlich unvollständige Auth.js-Instanz
|
// explicitly-provisioned HR user are sent to /login too, with an error
|
||||||
// — nur aus lib/auth/config.ts, ohne die Rückrufe aus auth.ts. Der Grund ist
|
// message, instead of being let through. Previously this only checked for
|
||||||
// handfest: auth.ts spricht mit der Datenbank, und der Proxy läuft je nach
|
// a Supabase Auth session at all, which meant any signed-in user (even one
|
||||||
// Betriebsart in einer Umgebung ohne Node-Module. Die Instanz hier liest das
|
// with no profile row, or the old "manager" role) could open the app.
|
||||||
// Sitzungscookie und sonst nichts.
|
// This UI-layer gate is defense in depth, not the real boundary — every
|
||||||
//
|
// table is independently RLS-gated on is_hr_user() regardless of what this
|
||||||
// Deshalb prüft der Proxy auch nur, *ob* jemand angemeldet ist — nicht mehr,
|
// proxy does.
|
||||||
// ob die Person HR-Rechte hat. Das ist keine Lücke, sondern eine Verschiebung
|
export async function proxy(request: NextRequest) {
|
||||||
// an die Stellen, die es wahrheitsgemäss beantworten können:
|
let response = NextResponse.next({ request });
|
||||||
//
|
|
||||||
// • app/(app)/layout.tsx fragt profiles bei jedem Aufbau frisch ab und
|
|
||||||
// leitet auf /login?error=no_hr_access um,
|
|
||||||
// • die Route Handler unter /api/export/* tun dasselbe über requireHrUser(),
|
|
||||||
// • und darunter, unabhängig von allem Anwendungscode, entscheiden die
|
|
||||||
// RLS-Policies über is_hr_user().
|
|
||||||
//
|
|
||||||
// Die Alternative — role und is_active ins Sitzungstoken schreiben — hätte
|
|
||||||
// den Proxy schneller gemacht und dafür eine Behauptung eingefroren: eine
|
|
||||||
// entzogene Freischaltung wirkte erst mit dem nächsten Token. Bei einer
|
|
||||||
// Personalanwendung ist das die falsche Richtung.
|
|
||||||
// Als Funktion übergeben, nicht als Objekt: siehe authConfig().
|
|
||||||
const { auth } = NextAuth(() => authConfig());
|
|
||||||
|
|
||||||
const gate = auth((request) => {
|
const supabase = createServerClient(
|
||||||
const { pathname } = request.nextUrl;
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
|
||||||
|
response = NextResponse.next({ request });
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Auth.js' eigene Endpunkte müssen durch, bevor es eine Sitzung gibt —
|
const {
|
||||||
// dort entsteht sie ja erst. Ohne diese Ausnahme leitet der Proxy den
|
data: { user },
|
||||||
// Rückweg aus Entra nach /login um und die Anmeldung kommt nie zustande.
|
} = await supabase.auth.getUser();
|
||||||
if (pathname.startsWith("/api/auth")) return NextResponse.next();
|
|
||||||
|
|
||||||
const isLoginRoute = pathname.startsWith("/login");
|
const isLoginRoute = request.nextUrl.pathname.startsWith("/login");
|
||||||
const isSignedIn = Boolean(request.auth?.user?.id);
|
|
||||||
|
|
||||||
if (!isSignedIn) {
|
if (!user) {
|
||||||
if (isLoginRoute) return NextResponse.next();
|
if (isLoginRoute) return response;
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
url.pathname = "/login";
|
url.pathname = "/login";
|
||||||
url.search = "";
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
|
||||||
|
const isActiveHr = profile?.role === "hr" && profile?.is_active === true;
|
||||||
|
|
||||||
|
if (!isActiveHr) {
|
||||||
|
if (isLoginRoute) return response;
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = "/login";
|
||||||
|
url.searchParams.set("error", "no_hr_access");
|
||||||
return NextResponse.redirect(url);
|
return NextResponse.redirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoginRoute) {
|
if (isLoginRoute) {
|
||||||
// Angemeldet und trotzdem auf /login: nur weiterschicken, wenn keine
|
|
||||||
// Meldung ansteht. Sonst geriete jemand ohne HR-Freischaltung in eine
|
|
||||||
// Schleife — das Layout leitet nach /login?error=no_hr_access, der Proxy
|
|
||||||
// zurück auf /, das Layout wieder … und der Grund wäre nie zu lesen.
|
|
||||||
if (request.nextUrl.searchParams.has("error")) return NextResponse.next();
|
|
||||||
const url = request.nextUrl.clone();
|
const url = request.nextUrl.clone();
|
||||||
url.pathname = "/";
|
url.pathname = "/";
|
||||||
url.search = "";
|
|
||||||
return NextResponse.redirect(url);
|
return NextResponse.redirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.next();
|
return response;
|
||||||
});
|
|
||||||
|
|
||||||
// Zwei Eigenheiten auf einmal, beide erst beim Ausprobieren aufgefallen:
|
|
||||||
//
|
|
||||||
// 1. Next.js sucht hier eine *Funktionsdeklaration* namens `proxy` (oder
|
|
||||||
// einen Default-Export) und erkennt `export const proxy = auth(…)` nicht.
|
|
||||||
// Jede Anfrage lief in einen 404 — und `next build` meldete Erfolg und
|
|
||||||
// listete den Proxy sogar auf.
|
|
||||||
//
|
|
||||||
// 2. In der Funktionsform liefert `auth(handler)` den Handler erst als
|
|
||||||
// Zusage. Ohne `await` steht hier ein Promise, und der Aufruf scheitert
|
|
||||||
// mit „gate is not a function". Auf einem gewöhnlichen Funktionswert ist
|
|
||||||
// `await` wirkungslos, das `await` ist also in beiden Fällen richtig.
|
|
||||||
export async function proxy(...args: Parameters<Awaited<typeof gate>>) {
|
|
||||||
return (await gate)(...args);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
// Baut den Organisationsbaum im SAP-OM-Modell aus der fachlichen
|
|
||||||
// Bereichsdefinition des Seeds.
|
|
||||||
//
|
|
||||||
// Bewusst frei von Zufall, Datenbank und IDs aus der Umgebung: die
|
|
||||||
// Konstruktion ist die Stelle, an der sich Nummernkreise, Leitungsplanstellen
|
|
||||||
// und die Elternbeziehung falsch verdrahten lassen, ohne dass es jemandem
|
|
||||||
// auffällt — ein Team unter dem falschen Bereich sieht im Organigramm
|
|
||||||
// plausibel aus. Deshalb ist sie eine reine Funktion mit Tests.
|
|
||||||
|
|
||||||
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
|
|
||||||
|
|
||||||
export type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number };
|
|
||||||
export type DeptDef = { name: string; leadTitle: string; teams: TeamDef[] };
|
|
||||||
export type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] };
|
|
||||||
|
|
||||||
export type BuiltUnit = {
|
|
||||||
id: string;
|
|
||||||
org_number: string;
|
|
||||||
name: string;
|
|
||||||
parent_id: string | null;
|
|
||||||
unit_type: OrgUnitType;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BuiltJob = { id: string; code: string; title: string };
|
|
||||||
|
|
||||||
export type BuiltPosition = {
|
|
||||||
id: string;
|
|
||||||
position_number: string;
|
|
||||||
org_unit_id: string;
|
|
||||||
job_id: string;
|
|
||||||
is_chief: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BuiltOrg = {
|
|
||||||
units: BuiltUnit[];
|
|
||||||
jobs: BuiltJob[];
|
|
||||||
positions: BuiltPosition[];
|
|
||||||
/** Für jedes Team die Planstellen der Mitarbeitenden, in Reihenfolge. */
|
|
||||||
icPositionsByTeam: Map<string, BuiltPosition[]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Nummernkreise wie im Altmodell, damit die Nummern der Einheiten über den
|
|
||||||
// Umstieg hinweg wiedererkennbar bleiben.
|
|
||||||
const PREFIX: Record<OrgUnitType, string> = {
|
|
||||||
Gesellschaft: "10",
|
|
||||||
Bereich: "20",
|
|
||||||
Abteilung: "21",
|
|
||||||
Team: "22",
|
|
||||||
};
|
|
||||||
|
|
||||||
function orgNumber(type: OrgUnitType, counter: number): string {
|
|
||||||
return `${PREFIX[type]}${String(counter).padStart(6, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Planstellennummern folgen dem bestehenden Muster ^6\d{7}$. */
|
|
||||||
function positionNumber(counter: number): string {
|
|
||||||
return `6${String(counter).padStart(7, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function jobCode(counter: number): string {
|
|
||||||
return `J${String(counter).padStart(4, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `newId` wird hereingereicht, damit die Funktion in Tests deterministisch
|
|
||||||
* bleibt und im Seed randomUUID benutzt.
|
|
||||||
*/
|
|
||||||
export function buildOrg(
|
|
||||||
companyName: string,
|
|
||||||
divisions: DivisionDef[],
|
|
||||||
newId: () => string
|
|
||||||
): BuiltOrg {
|
|
||||||
const units: BuiltUnit[] = [];
|
|
||||||
const positions: BuiltPosition[] = [];
|
|
||||||
const icPositionsByTeam = new Map<string, BuiltPosition[]>();
|
|
||||||
|
|
||||||
// Job-Katalog: gleiche Tätigkeit, ein Eintrag. Vorher war job_title
|
|
||||||
// Freitext je Person, weshalb sich Tätigkeiten nicht auswerten liessen.
|
|
||||||
const jobIdByTitle = new Map<string, string>();
|
|
||||||
const jobs: BuiltJob[] = [];
|
|
||||||
function jobFor(title: string): string {
|
|
||||||
const existing = jobIdByTitle.get(title);
|
|
||||||
if (existing) return existing;
|
|
||||||
const job = { id: newId(), code: jobCode(jobs.length + 1), title };
|
|
||||||
jobs.push(job);
|
|
||||||
jobIdByTitle.set(title, job.id);
|
|
||||||
return job.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
let unitCounter = { Gesellschaft: 0, Bereich: 0, Abteilung: 0, Team: 0 };
|
|
||||||
let positionCounter = 0;
|
|
||||||
|
|
||||||
function addUnit(name: string, type: OrgUnitType, parentId: string | null): BuiltUnit {
|
|
||||||
unitCounter = { ...unitCounter, [type]: unitCounter[type] + 1 };
|
|
||||||
const unit: BuiltUnit = {
|
|
||||||
id: newId(),
|
|
||||||
org_number: orgNumber(type, unitCounter[type] * (type === "Team" ? 1000 : type === "Abteilung" ? 10000 : 100000)),
|
|
||||||
name,
|
|
||||||
parent_id: parentId,
|
|
||||||
unit_type: type,
|
|
||||||
};
|
|
||||||
units.push(unit);
|
|
||||||
return unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPosition(unitId: string, title: string, isChief: boolean): BuiltPosition {
|
|
||||||
positionCounter += 1;
|
|
||||||
const position: BuiltPosition = {
|
|
||||||
id: newId(),
|
|
||||||
position_number: positionNumber(positionCounter),
|
|
||||||
org_unit_id: unitId,
|
|
||||||
job_id: jobFor(title),
|
|
||||||
is_chief: isChief,
|
|
||||||
};
|
|
||||||
positions.push(position);
|
|
||||||
return position;
|
|
||||||
}
|
|
||||||
|
|
||||||
const company = addUnit(companyName, "Gesellschaft", null);
|
|
||||||
addPosition(company.id, "Geschäftsführer:in", true);
|
|
||||||
// Die Assistenz hängt an der Gesellschaft, führt sie aber nicht — genau
|
|
||||||
// die Unterscheidung, die es im Altmodell nicht gab.
|
|
||||||
addPosition(company.id, "Assistenz der Geschäftsführung", false);
|
|
||||||
|
|
||||||
for (const div of divisions) {
|
|
||||||
const bereich = addUnit(div.name, "Bereich", company.id);
|
|
||||||
addPosition(bereich.id, div.headTitle, true);
|
|
||||||
|
|
||||||
for (const dept of div.departments) {
|
|
||||||
const abteilung = addUnit(dept.name, "Abteilung", bereich.id);
|
|
||||||
// Die Ebene, die im Altmodell gefehlt hat: eine Abteilung hat jetzt
|
|
||||||
// eine eigene Leitungsplanstelle.
|
|
||||||
addPosition(abteilung.id, dept.leadTitle, true);
|
|
||||||
|
|
||||||
for (const team of dept.teams) {
|
|
||||||
const teamUnit = addUnit(team.name, "Team", abteilung.id);
|
|
||||||
addPosition(teamUnit.id, team.leadTitle, true);
|
|
||||||
|
|
||||||
const size = Math.max(1, team.baseSize);
|
|
||||||
const icPositions = Array.from({ length: size }, (_, i) =>
|
|
||||||
addPosition(teamUnit.id, team.icTitles[i % team.icTitles.length], false)
|
|
||||||
);
|
|
||||||
icPositionsByTeam.set(teamUnit.id, icPositions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { units, jobs, positions, icPositionsByTeam };
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
-- Organisationsmanagement nach SAP-OM-Vorbild.
|
|
||||||
--
|
|
||||||
-- Bisher: drei feste Tabellen (divisions -> departments -> teams) und
|
|
||||||
-- Personen, die direkt daran hängen (employees.division_id/team_id) mit
|
|
||||||
-- einer frei gepflegten manager_id. Damit ist die Hierarchie in ihrer Tiefe
|
|
||||||
-- fest verdrahtet — eine Abteilungsleitung liess sich nicht abbilden, ohne
|
|
||||||
-- das Schema zu ändern, und ein Team direkt unter einem Bereich gar nicht.
|
|
||||||
--
|
|
||||||
-- SAP OM löst das über wenige Objekttypen und Verknüpfungen dazwischen:
|
|
||||||
--
|
|
||||||
-- O Organisationseinheit org_units (rekursiv über parent_id)
|
|
||||||
-- C Stelle / Job jobs (Katalog)
|
|
||||||
-- S Planstelle positions (gehört zu genau einer O)
|
|
||||||
-- P Person employees (besetzt eine S)
|
|
||||||
--
|
|
||||||
-- A003 "gehört zu" positions.org_unit_id
|
|
||||||
-- A012 "ist Leiter von" positions.is_chief
|
|
||||||
-- A008 "Inhaber ist" position_assignments
|
|
||||||
--
|
|
||||||
-- Die Berichtslinie wird daraus abgeleitet statt gepflegt, siehe die
|
|
||||||
-- folgende Migration. Ebenen sind nur noch ein Etikett (unit_type), keine
|
|
||||||
-- Struktur — eine fünfte Ebene ist damit eine Datenfrage, keine Migration.
|
|
||||||
|
|
||||||
-- ── O: Organisationseinheit ────────────────────────────────────────
|
|
||||||
create type org_unit_type as enum ('Gesellschaft', 'Bereich', 'Abteilung', 'Team');
|
|
||||||
|
|
||||||
create table org_units (
|
|
||||||
id uuid primary key default gen_random_uuid(),
|
|
||||||
org_number text not null unique,
|
|
||||||
name text not null,
|
|
||||||
-- Die Hierarchie selbst. Null nur für die Wurzel.
|
|
||||||
parent_id uuid references org_units(id),
|
|
||||||
-- Nur Beschriftung und Nummernkreis-Konvention; die Struktur steckt in
|
|
||||||
-- parent_id. Eine Abteilung unter einer Abteilung wäre technisch möglich
|
|
||||||
-- und ist bewusst nicht verboten.
|
|
||||||
unit_type org_unit_type not null,
|
|
||||||
valid_from date not null default current_date,
|
|
||||||
valid_to date,
|
|
||||||
created_at timestamptz not null default now(),
|
|
||||||
constraint chk_org_unit_range check (valid_to is null or valid_to > valid_from),
|
|
||||||
constraint chk_org_unit_not_own_parent check (parent_id is null or parent_id <> id)
|
|
||||||
);
|
|
||||||
|
|
||||||
create index on org_units (parent_id);
|
|
||||||
create index on org_units (unit_type);
|
|
||||||
-- Genau eine Wurzel: ohne das kann ein Fehlgriff beim Import einen zweiten
|
|
||||||
-- Baum aufmachen, und die Ableitung der Berichtslinie liefe ins Leere.
|
|
||||||
create unique index org_units_single_root on org_units ((parent_id is null)) where parent_id is null;
|
|
||||||
|
|
||||||
comment on table org_units is 'SAP-OM-Objekttyp O. Rekursiv über parent_id; unit_type ist nur ein Etikett.';
|
|
||||||
|
|
||||||
-- ── C: Stelle / Job-Katalog ────────────────────────────────────────
|
|
||||||
-- Trennt die Tätigkeitsbeschreibung von der einzelnen Planstelle: viele
|
|
||||||
-- Planstellen teilen sich einen Job. Bisher war job_title Freitext je
|
|
||||||
-- Person, weshalb "Schlosser:in" und "Schlosser" nebeneinander existieren
|
|
||||||
-- konnten und keine Auswertung über Tätigkeiten möglich war.
|
|
||||||
create table jobs (
|
|
||||||
id uuid primary key default gen_random_uuid(),
|
|
||||||
code text not null unique,
|
|
||||||
title text not null unique,
|
|
||||||
created_at timestamptz not null default now()
|
|
||||||
);
|
|
||||||
|
|
||||||
comment on table jobs is 'SAP-OM-Objekttyp C. Katalog der Tätigkeiten; Planstellen verweisen darauf.';
|
|
||||||
|
|
||||||
-- ── S: Planstelle ──────────────────────────────────────────────────
|
|
||||||
-- Anders als die bisherige positions-Tabelle, die nur *offene* Stellen
|
|
||||||
-- führte: hier bekommt jede Person eine Planstelle. Eine offene Stelle ist
|
|
||||||
-- schlicht eine Planstelle ohne laufende Besetzung — Vakanz ist damit eine
|
|
||||||
-- Eigenschaft der Planstelle, kein eigenes Objekt.
|
|
||||||
create table om_positions (
|
|
||||||
id uuid primary key default gen_random_uuid(),
|
|
||||||
position_number text not null unique,
|
|
||||||
org_unit_id uuid not null references org_units(id),
|
|
||||||
job_id uuid not null references jobs(id),
|
|
||||||
-- A012 "ist Leiter von": diese Planstelle führt ihre Organisationseinheit.
|
|
||||||
is_chief boolean not null default false,
|
|
||||||
valid_from date not null default current_date,
|
|
||||||
valid_to date,
|
|
||||||
created_at timestamptz not null default now(),
|
|
||||||
constraint chk_om_position_range check (valid_to is null or valid_to > valid_from)
|
|
||||||
);
|
|
||||||
|
|
||||||
create index on om_positions (org_unit_id);
|
|
||||||
create index on om_positions (job_id);
|
|
||||||
-- Höchstens eine Leitungsplanstelle je Einheit, solange sie gültig ist.
|
|
||||||
create unique index om_positions_one_chief on om_positions (org_unit_id) where is_chief and valid_to is null;
|
|
||||||
|
|
||||||
comment on table om_positions is 'SAP-OM-Objekttyp S. is_chief entspricht der Verknüpfung A012 "ist Leiter von".';
|
|
||||||
|
|
||||||
-- ── A008: Person besetzt Planstelle ────────────────────────────────
|
|
||||||
create table position_assignments (
|
|
||||||
id uuid primary key default gen_random_uuid(),
|
|
||||||
position_id uuid not null references om_positions(id) on delete cascade,
|
|
||||||
employee_id uuid not null references employees(id) on delete cascade,
|
|
||||||
valid_from date not null,
|
|
||||||
valid_to date,
|
|
||||||
created_at timestamptz not null default now(),
|
|
||||||
constraint chk_assignment_range check (valid_to is null or valid_to > valid_from)
|
|
||||||
);
|
|
||||||
|
|
||||||
create index on position_assignments (position_id);
|
|
||||||
create index on position_assignments (employee_id);
|
|
||||||
-- Eine Planstelle ist zu einem Zeitpunkt von höchstens einer Person besetzt,
|
|
||||||
-- und eine Person hat höchstens eine laufende Planstelle. Beides sind die
|
|
||||||
-- Invarianten, auf die sich die Ableitung der Berichtslinie stützt.
|
|
||||||
create unique index position_assignments_one_holder on position_assignments (position_id) where valid_to is null;
|
|
||||||
create unique index position_assignments_one_position on position_assignments (employee_id) where valid_to is null;
|
|
||||||
|
|
||||||
comment on table position_assignments is 'SAP-OM-Verknüpfung A008 "Inhaber ist", zeitabhängig.';
|
|
||||||
|
|
||||||
-- ── RLS, wie bei allen anderen Tabellen ────────────────────────────
|
|
||||||
alter table org_units enable row level security;
|
|
||||||
alter table jobs enable row level security;
|
|
||||||
alter table om_positions enable row level security;
|
|
||||||
alter table position_assignments enable row level security;
|
|
||||||
|
|
||||||
create policy "org_units_hr_all" on org_units for all using (is_hr_user()) with check (is_hr_user());
|
|
||||||
create policy "jobs_hr_all" on jobs for all using (is_hr_user()) with check (is_hr_user());
|
|
||||||
create policy "om_positions_hr_all" on om_positions for all using (is_hr_user()) with check (is_hr_user());
|
|
||||||
create policy "position_assignments_hr_all" on position_assignments for all using (is_hr_user()) with check (is_hr_user());
|
|
||||||
|
|
||||||
grant all on table org_units, jobs, om_positions, position_assignments to anon, authenticated, service_role;
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
-- Die Berichtslinie wird abgeleitet, nicht gepflegt.
|
|
||||||
--
|
|
||||||
-- Bisher stand sie als employees.manager_id in der Tabelle und wurde von
|
|
||||||
-- resolve_manager_for() bei jeder Mutation neu geraten. Damit konnte sie von
|
|
||||||
-- der Organisationsstruktur abweichen, und tat es auch.
|
|
||||||
--
|
|
||||||
-- SAP-OM-Regel, hier eins zu eins:
|
|
||||||
--
|
|
||||||
-- Wer eine gewöhnliche Planstelle innehat, berichtet an die Leitung der
|
|
||||||
-- eigenen Organisationseinheit. Wer selbst die Leitung innehat, berichtet
|
|
||||||
-- an die Leitung der übergeordneten Einheit.
|
|
||||||
--
|
|
||||||
-- Dazu kommt die Aufwärtsregel: Ist diese Leitungsplanstelle unbesetzt oder
|
|
||||||
-- ihre Inhaberin langzeitabwesend, geht es weiter nach oben, bis eine
|
|
||||||
-- besetzte und anwesende Leitung gefunden ist. Genau deshalb braucht eine
|
|
||||||
-- unbesetzte Abteilungsleitung keine Sonderbehandlung — sie wird schlicht
|
|
||||||
-- übersprungen.
|
|
||||||
--
|
|
||||||
-- Beides wird zurückgegeben: die formale Leitung (auch wenn abwesend) und
|
|
||||||
-- die tatsächliche. Nur so lässt sich in der Oberfläche zeigen, dass eine
|
|
||||||
-- Vertretung im Spiel ist, statt sie stillschweigend als die echte
|
|
||||||
-- Führungskraft auszugeben.
|
|
||||||
|
|
||||||
create or replace function om_reporting_lines(p_as_of date default current_date)
|
|
||||||
returns table (
|
|
||||||
employee_id uuid,
|
|
||||||
position_id uuid,
|
|
||||||
org_unit_id uuid,
|
|
||||||
is_chief boolean,
|
|
||||||
formal_manager_id uuid,
|
|
||||||
acting_manager_id uuid
|
|
||||||
)
|
|
||||||
language sql
|
|
||||||
stable
|
|
||||||
as $$
|
|
||||||
with recursive
|
|
||||||
-- Laufende Besetzungen: Planstelle und Zuordnung müssen beide am Stichtag
|
|
||||||
-- gültig sein.
|
|
||||||
holder as (
|
|
||||||
select pa.employee_id, pa.position_id, p.org_unit_id, p.is_chief
|
|
||||||
from position_assignments pa
|
|
||||||
join om_positions p on p.id = pa.position_id
|
|
||||||
where pa.valid_from <= p_as_of and (pa.valid_to is null or pa.valid_to > p_as_of)
|
|
||||||
and p.valid_from <= p_as_of and (p.valid_to is null or p.valid_to > p_as_of)
|
|
||||||
),
|
|
||||||
-- Leitung je Einheit, samt Abwesenheit am Stichtag. Die Ableitung ist
|
|
||||||
-- dieselbe wie in deriveStatusAsOf() auf der Anwendungsseite.
|
|
||||||
chief as (
|
|
||||||
select h.org_unit_id, h.employee_id,
|
|
||||||
(e.karenz_start_date is not null
|
|
||||||
and e.karenz_start_date <= p_as_of
|
|
||||||
and (e.karenz_return_date is null or p_as_of < e.karenz_return_date)) as absent
|
|
||||||
from holder h
|
|
||||||
join employees e on e.id = h.employee_id
|
|
||||||
where h.is_chief
|
|
||||||
),
|
|
||||||
-- Vorfahrenkette je Einheit; Tiefe 0 ist die Einheit selbst. Bei rund
|
|
||||||
-- sechzig Einheiten ist das billig, und es macht die Suche nach der
|
|
||||||
-- nächsten geeigneten Leitung zu einem einfachen "erster Treffer".
|
|
||||||
ancestry as (
|
|
||||||
select u.id as unit_id, u.id as ancestor_id, u.parent_id, 0 as depth
|
|
||||||
from org_units u
|
|
||||||
union all
|
|
||||||
select a.unit_id, p.id, p.parent_id, a.depth + 1
|
|
||||||
from ancestry a
|
|
||||||
join org_units p on p.id = a.parent_id
|
|
||||||
),
|
|
||||||
-- Die Einheit, ab der gesucht wird: für eine Leitung die übergeordnete,
|
|
||||||
-- sonst die eigene.
|
|
||||||
base as (
|
|
||||||
select h.employee_id, h.position_id, h.org_unit_id, h.is_chief,
|
|
||||||
case when h.is_chief then u.parent_id else h.org_unit_id end as base_unit_id
|
|
||||||
from holder h
|
|
||||||
join org_units u on u.id = h.org_unit_id
|
|
||||||
)
|
|
||||||
select
|
|
||||||
b.employee_id,
|
|
||||||
b.position_id,
|
|
||||||
b.org_unit_id,
|
|
||||||
b.is_chief,
|
|
||||||
-- Formale Leitung: die der Ausgangseinheit, unabhängig von Abwesenheit.
|
|
||||||
(select c.employee_id from chief c where c.org_unit_id = b.base_unit_id) as formal_manager_id,
|
|
||||||
-- Tatsächliche Leitung: die nächste besetzte und anwesende oberhalb,
|
|
||||||
-- die Ausgangseinheit eingeschlossen.
|
|
||||||
(
|
|
||||||
select c.employee_id
|
|
||||||
from ancestry a
|
|
||||||
join chief c on c.org_unit_id = a.ancestor_id
|
|
||||||
where a.unit_id = b.base_unit_id
|
|
||||||
and not c.absent
|
|
||||||
and c.employee_id <> b.employee_id
|
|
||||||
order by a.depth
|
|
||||||
limit 1
|
|
||||||
) as acting_manager_id
|
|
||||||
from base b;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
comment on function om_reporting_lines(date) is
|
|
||||||
'Leitet die Berichtslinie zum Stichtag aus dem Organisationsbaum ab. formal_manager_id ist die zuständige Leitung, acting_manager_id die nächste besetzte und anwesende darüber.';
|
|
||||||
|
|
||||||
grant execute on function om_reporting_lines(date) to anon, authenticated, service_role;
|
|
||||||
@@ -1,539 +0,0 @@
|
|||||||
-- Umstieg auf das SAP-OM-Modell: Altbestand überführen, Altmodell entfernen.
|
|
||||||
--
|
|
||||||
-- Läuft nach 20260727120000 (Tabellen) und 20260727120100 (Berichtslinie).
|
|
||||||
--
|
|
||||||
-- Warum ein Schnitt und keine schrittweise Migration: Sobald eine
|
|
||||||
-- Abteilungsleitung besetzt ist, liefert das alte resolve_manager_for()
|
|
||||||
-- falsche Ergebnisse. Es sucht die Bereichsleitung über
|
|
||||||
-- "division_id = X and team_id is null and org_level = 1" — eine
|
|
||||||
-- Abteilungsleitung erfüllt dieselbe Bedingung, und das LIMIT 1 greift dann
|
|
||||||
-- willkürlich eine von beiden.
|
|
||||||
--
|
|
||||||
-- Der Bestand wird überführt, nicht gelöscht. Direkt nach dem Ausführen ist
|
|
||||||
-- das Organigramm gefüllt.
|
|
||||||
--
|
|
||||||
-- Die Abteilungsleitungen entstehen als *unbesetzte* Planstellen: es gibt
|
|
||||||
-- niemanden, der sie innehat, und erfundene Zuordnungen wären schlechter als
|
|
||||||
-- eine sichtbare Lücke. Die Aufwärtsregel überspringt sie, bis sie besetzt
|
|
||||||
-- sind — die Berichtslinie bleibt durchgängig.
|
|
||||||
|
|
||||||
begin;
|
|
||||||
|
|
||||||
-- ═══ 1. Organisationseinheiten ═══════════════════════════════════
|
|
||||||
|
|
||||||
-- Wurzel. Die bisherige Pseudo-Division "Geschäftsführung" wird sie, damit
|
|
||||||
-- die Personen, die daran hingen, ihre Einheit behalten.
|
|
||||||
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
|
|
||||||
select id, '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01'
|
|
||||||
from divisions where name = 'Geschäftsführung';
|
|
||||||
|
|
||||||
-- Falls es sie nicht gab, eine neue Wurzel anlegen.
|
|
||||||
insert into org_units (org_number, name, parent_id, unit_type, valid_from)
|
|
||||||
select '10000000', 'Alpenwerk Industrie GmbH', null, 'Gesellschaft', '2000-01-01'
|
|
||||||
where not exists (select 1 from org_units where unit_type = 'Gesellschaft');
|
|
||||||
|
|
||||||
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
|
|
||||||
select d.id, d.org_number, d.name,
|
|
||||||
(select id from org_units where unit_type = 'Gesellschaft'),
|
|
||||||
'Bereich', '2000-01-01'
|
|
||||||
from divisions d
|
|
||||||
where d.name <> 'Geschäftsführung';
|
|
||||||
|
|
||||||
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
|
|
||||||
select dep.id, dep.org_number, dep.name,
|
|
||||||
coalesce((select u.id from org_units u where u.id = dep.division_id),
|
|
||||||
(select id from org_units where unit_type = 'Gesellschaft')),
|
|
||||||
'Abteilung', '2000-01-01'
|
|
||||||
from departments dep;
|
|
||||||
|
|
||||||
insert into org_units (id, org_number, name, parent_id, unit_type, valid_from)
|
|
||||||
select t.id, t.org_number, t.name, t.department_id, 'Team', '2000-01-01'
|
|
||||||
from teams t;
|
|
||||||
|
|
||||||
-- ═══ 2. Job-Katalog ══════════════════════════════════════════════
|
|
||||||
-- Vollständig *vor* den Planstellen, die darauf verweisen. Enthält auch die
|
|
||||||
-- Titel der neuen Abteilungsleitungen.
|
|
||||||
insert into jobs (code, title)
|
|
||||||
select 'J' || lpad(row_number() over (order by title)::text, 4, '0'), title
|
|
||||||
from (
|
|
||||||
select distinct job_title as title from employees where job_title is not null
|
|
||||||
union
|
|
||||||
select distinct title from positions where title is not null
|
|
||||||
union
|
|
||||||
select distinct 'Abteilungsleitung ' || name from org_units where unit_type = 'Abteilung'
|
|
||||||
) t
|
|
||||||
on conflict (title) do nothing;
|
|
||||||
|
|
||||||
-- ═══ 3. Planstellen und Besetzungen ══════════════════════════════
|
|
||||||
|
|
||||||
-- Die Zuordnung Person -> Planstelle wird einmal festgelegt und dann von
|
|
||||||
-- beiden Inserts benutzt. Zwei unabhängig berechnete Fensterfunktionen
|
|
||||||
-- wären hier die klassische Fehlerquelle: sie sehen gleich aus und ordnen
|
|
||||||
-- doch verschieden.
|
|
||||||
-- Kein "on commit drop": im SQL-Editor hängt es vom Transaktionsverhalten
|
|
||||||
-- ab, wann das greift, und eine zu früh verschwundene Zuordnungstabelle
|
|
||||||
-- wäre schwer zu diagnostizieren. Wird am Ende explizit entfernt.
|
|
||||||
create temporary table om_pos_map (
|
|
||||||
employee_id uuid primary key,
|
|
||||||
position_id uuid not null default gen_random_uuid(),
|
|
||||||
seq bigint
|
|
||||||
);
|
|
||||||
|
|
||||||
insert into om_pos_map (employee_id, seq)
|
|
||||||
select id, row_number() over (order by org_level, personnel_number) from employees;
|
|
||||||
|
|
||||||
insert into om_positions (id, position_number, org_unit_id, job_id, is_chief, valid_from)
|
|
||||||
select
|
|
||||||
m.position_id,
|
|
||||||
'6' || lpad(m.seq::text, 7, '0'),
|
|
||||||
case
|
|
||||||
when e.org_level = 0 then (select id from org_units where unit_type = 'Gesellschaft')
|
|
||||||
when e.org_level = 1 then coalesce(e.division_id, (select id from org_units where unit_type = 'Gesellschaft'))
|
|
||||||
else coalesce(e.team_id, (select id from org_units where unit_type = 'Gesellschaft'))
|
|
||||||
end,
|
|
||||||
(select j.id from jobs j where j.title = e.job_title),
|
|
||||||
(e.org_level <= 1 or (e.org_level = 2 and e.is_lead)),
|
|
||||||
e.entry_date
|
|
||||||
from employees e
|
|
||||||
join om_pos_map m on m.employee_id = e.id;
|
|
||||||
|
|
||||||
-- Wer ausgetreten ist, hat eine beendete Besetzung: die Planstelle ist
|
|
||||||
-- wieder frei, die Historie bleibt.
|
|
||||||
--
|
|
||||||
-- greatest(): employees erlaubt exit_date = entry_date, die Prüfregel auf
|
|
||||||
-- position_assignments verlangt aber valid_to > valid_from. Ein
|
|
||||||
-- gleichtägiger Ein- und Austritt würde die Migration sonst abbrechen.
|
|
||||||
insert into position_assignments (position_id, employee_id, valid_from, valid_to)
|
|
||||||
select m.position_id, e.id, e.entry_date,
|
|
||||||
case when e.exit_date is null then null else greatest(e.exit_date, e.entry_date + 1) end
|
|
||||||
from employees e
|
|
||||||
join om_pos_map m on m.employee_id = e.id;
|
|
||||||
|
|
||||||
-- Unbesetzte Abteilungsleitungen — die Ebene, die im Altmodell fehlte.
|
|
||||||
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
|
|
||||||
select '69' || lpad(row_number() over (order by u.org_number)::text, 6, '0'),
|
|
||||||
u.id,
|
|
||||||
(select id from jobs where title = 'Abteilungsleitung ' || u.name),
|
|
||||||
true,
|
|
||||||
current_date
|
|
||||||
from org_units u
|
|
||||||
where u.unit_type = 'Abteilung'
|
|
||||||
and not exists (select 1 from om_positions p where p.org_unit_id = u.id and p.is_chief);
|
|
||||||
|
|
||||||
-- Bisher offene Stellen werden unbesetzte Planstellen. is_chief nur, wenn
|
|
||||||
-- die Einheit noch keine Leitung hat — der Unique-Index liesse es sonst
|
|
||||||
-- ohnehin nicht zu.
|
|
||||||
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
|
|
||||||
select p.position_number, p.team_id,
|
|
||||||
(select id from jobs j where j.title = p.title),
|
|
||||||
p.is_lead and not exists (select 1 from om_positions o where o.org_unit_id = p.team_id and o.is_chief),
|
|
||||||
p.valid_from
|
|
||||||
from positions p
|
|
||||||
where p.status = 'open'
|
|
||||||
and exists (select 1 from org_units u where u.id = p.team_id);
|
|
||||||
|
|
||||||
-- Abbruch, bevor das Altmodell fällt: lieber eine gescheiterte Migration
|
|
||||||
-- als ein halb überführter Bestand ohne Rückweg.
|
|
||||||
do $$
|
|
||||||
declare v_fehlend int;
|
|
||||||
begin
|
|
||||||
select count(*) into v_fehlend
|
|
||||||
from employees e
|
|
||||||
where not exists (select 1 from position_assignments pa where pa.employee_id = e.id);
|
|
||||||
if v_fehlend > 0 then
|
|
||||||
raise exception 'Abbruch: % Mitarbeitende ohne Planstelle.', v_fehlend;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
select count(*) into v_fehlend from om_positions where job_id is null;
|
|
||||||
if v_fehlend > 0 then
|
|
||||||
raise exception 'Abbruch: % Planstellen ohne Job.', v_fehlend;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
select count(*) into v_fehlend
|
|
||||||
from org_units u
|
|
||||||
where u.parent_id is null and u.unit_type <> 'Gesellschaft';
|
|
||||||
if v_fehlend > 0 then
|
|
||||||
raise exception 'Abbruch: % Einheiten ohne Elternteil.', v_fehlend;
|
|
||||||
end if;
|
|
||||||
end $$;
|
|
||||||
|
|
||||||
drop table om_pos_map;
|
|
||||||
|
|
||||||
-- ═══ 4. Altmodell entfernen ══════════════════════════════════════
|
|
||||||
|
|
||||||
-- Vorgemerkte Änderungen verweisen über team_id auf das Altmodell. Sie sind
|
|
||||||
-- transient; halb übersetzt wären sie schlimmer als verworfen.
|
|
||||||
delete from pending_org_changes where status = 'pending';
|
|
||||||
|
|
||||||
drop trigger if exists trg_track_employee_assignment on employees;
|
|
||||||
drop function if exists fn_track_employee_assignment();
|
|
||||||
drop table if exists employee_assignments;
|
|
||||||
|
|
||||||
-- Leitet division_id aus team_id ab und haengt damit an einer Spalte, die
|
|
||||||
-- gleich faellt. Im neuen Modell uebernimmt die Einheit der Planstelle
|
|
||||||
-- diese Rolle, der Trigger wird ersatzlos entfernt.
|
|
||||||
drop trigger if exists trg_employees_set_org_unit on employees;
|
|
||||||
drop function if exists fn_set_employee_org_unit();
|
|
||||||
|
|
||||||
-- Die View selektiert team_id/division_id/manager_id/org_level/is_lead und
|
|
||||||
-- blockiert damit das Entfernen dieser Spalten. Sie wird von der Anwendung
|
|
||||||
-- nirgends benutzt und ersatzlos entfernt; die Ableitung über
|
|
||||||
-- om_reporting_lines() tritt an ihre Stelle.
|
|
||||||
drop view if exists employees_directory;
|
|
||||||
|
|
||||||
-- Funktionen auf den Alt-Spalten. Die weiterhin benötigten werden in
|
|
||||||
-- Abschnitt 5 neu angelegt; Reorganisation und Ausschreibung folgen mit der
|
|
||||||
-- Umstellung der Oberfläche.
|
|
||||||
drop function if exists resolve_manager_for(uuid, boolean, uuid);
|
|
||||||
drop function if exists staff_position_internally(jsonb);
|
|
||||||
drop function if exists create_position(jsonb);
|
|
||||||
drop function if exists delete_position(uuid);
|
|
||||||
drop function if exists apply_reorg(jsonb);
|
|
||||||
drop function if exists undo_reorg(uuid);
|
|
||||||
drop function if exists hire_employee(jsonb);
|
|
||||||
drop function if exists terminate_employee(jsonb);
|
|
||||||
drop function if exists transfer_employee(jsonb);
|
|
||||||
drop function if exists rehire_employee(jsonb);
|
|
||||||
drop function if exists record_karenz_return(jsonb);
|
|
||||||
drop function if exists apply_due_pending_changes();
|
|
||||||
|
|
||||||
alter table employees
|
|
||||||
drop column if exists division_id,
|
|
||||||
drop column if exists team_id,
|
|
||||||
drop column if exists manager_id,
|
|
||||||
drop column if exists org_level,
|
|
||||||
drop column if exists is_lead;
|
|
||||||
|
|
||||||
drop table if exists positions;
|
|
||||||
drop table if exists teams;
|
|
||||||
drop table if exists departments;
|
|
||||||
drop table if exists divisions;
|
|
||||||
|
|
||||||
-- Mit der positions-Tabelle verschwindet ihr Trigger, nicht aber dessen
|
|
||||||
-- Funktion und die Nummernvergabe, die nur als Spaltenvorgabe dort benutzt
|
|
||||||
-- wurde. om_positions vergibt seine Nummern selbst.
|
|
||||||
drop function if exists fn_set_position_org_unit();
|
|
||||||
drop function if exists generate_position_number();
|
|
||||||
|
|
||||||
-- ═══ 5. Mutationen im neuen Modell ═══════════════════════════════
|
|
||||||
-- Die Berichtslinie wird nicht mehr mitgeschrieben, sondern abgeleitet.
|
|
||||||
-- Das entfernt aus jeder dieser Funktionen die Manager-Nachführung — beim
|
|
||||||
-- Austritt etwa entfällt das Umhängen der direkten Berichte vollständig,
|
|
||||||
-- weil sie ohnehin auf die nächste besetzte Ebene hochrutschen.
|
|
||||||
|
|
||||||
create or replace function hire_employee(payload jsonb)
|
|
||||||
returns uuid language plpgsql as $$
|
|
||||||
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;
|
|
||||||
|
|
||||||
select pa.employee_id into v_besetzt
|
|
||||||
from position_assignments pa
|
|
||||||
where pa.position_id = v_position_id and pa.valid_to is null;
|
|
||||||
if v_besetzt is not null then
|
|
||||||
raise exception 'Diese Planstelle ist bereits besetzt.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
insert into employees (
|
|
||||||
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
|
|
||||||
)
|
|
||||||
values (
|
|
||||||
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(array(select jsonb_array_elements_text(payload->'work_days'))::weekday[], '{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)
|
|
||||||
)
|
|
||||||
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 (auth.uid(), current_actor_name(), 'Neueinstellung',
|
|
||||||
payload->>'first_name' || ' ' || payload->>'last_name', v_id, 'Eintritt am ' || v_entry);
|
|
||||||
|
|
||||||
return v_id;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
create or replace function terminate_employee(payload jsonb)
|
|
||||||
returns void language plpgsql as $$
|
|
||||||
declare
|
|
||||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
|
||||||
v_exit date := (payload->>'exit_date')::date;
|
|
||||||
v_name text;
|
|
||||||
begin
|
|
||||||
perform require_hr_admin();
|
|
||||||
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
|
||||||
|
|
||||||
update employees set
|
|
||||||
status = case when 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.
|
|
||||||
update position_assignments set valid_to = v_exit
|
|
||||||
where employee_id = v_employee_id and valid_to is null;
|
|
||||||
|
|
||||||
insert into employee_history (employee_id, event_date, event_type, description)
|
|
||||||
values (v_employee_id, v_exit, 'Austritt', 'Austritt (' || coalesce(payload->>'exit_reason', '-') || ')');
|
|
||||||
|
|
||||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
|
||||||
values (auth.uid(), current_actor_name(), 'Austritt', v_name, v_employee_id, 'Austritt am ' || v_exit);
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- Versetzung ist im OM-Modell ein Wechsel der Planstelle: die alte
|
|
||||||
-- Besetzung endet, die neue beginnt. Bereich, Abteilung und Team ergeben
|
|
||||||
-- sich aus der Einheit der Zielplanstelle und werden nicht mehr mitgeführt.
|
|
||||||
create or replace function transfer_employee(payload jsonb)
|
|
||||||
returns void language plpgsql as $$
|
|
||||||
declare
|
|
||||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
|
||||||
v_target_position uuid := (payload->>'target_position_id')::uuid;
|
|
||||||
v_effective date := coalesce(nullif(payload->>'effective_date','')::date, current_date);
|
|
||||||
v_name text;
|
|
||||||
v_besetzt uuid;
|
|
||||||
begin
|
|
||||||
perform require_hr_admin();
|
|
||||||
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
|
||||||
|
|
||||||
select pa.employee_id into v_besetzt
|
|
||||||
from position_assignments pa
|
|
||||||
where pa.position_id = v_target_position and pa.valid_to is null;
|
|
||||||
if v_besetzt is not null and v_besetzt <> v_employee_id then
|
|
||||||
raise exception 'Die Zielplanstelle ist bereits besetzt.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
if v_effective <= current_date then
|
|
||||||
update position_assignments set valid_to = v_effective
|
|
||||||
where employee_id = v_employee_id and valid_to is null;
|
|
||||||
insert into position_assignments (position_id, employee_id, valid_from)
|
|
||||||
values (v_target_position, v_employee_id, v_effective);
|
|
||||||
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_target_position)
|
|
||||||
where id = v_employee_id;
|
|
||||||
else
|
|
||||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
|
||||||
values (v_employee_id, 'transfer', v_effective,
|
|
||||||
jsonb_build_object('target_position_id', v_target_position));
|
|
||||||
end if;
|
|
||||||
|
|
||||||
insert into employee_history (employee_id, event_date, event_type, description)
|
|
||||||
values (v_employee_id, v_effective, 'Versetzung', 'Versetzung auf Planstelle ' ||
|
|
||||||
(select position_number from om_positions where id = v_target_position));
|
|
||||||
|
|
||||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
|
||||||
values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || v_effective);
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
create or replace function rehire_employee(payload jsonb)
|
|
||||||
returns void language plpgsql as $$
|
|
||||||
declare
|
|
||||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
|
||||||
v_date date := (payload->>'rehire_date')::date;
|
|
||||||
v_position_id uuid := (payload->>'position_id')::uuid;
|
|
||||||
v_name text;
|
|
||||||
begin
|
|
||||||
perform require_hr_admin();
|
|
||||||
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
|
||||||
|
|
||||||
if v_position_id is null then
|
|
||||||
raise exception 'Für die Wiedereinstellung muss eine Planstelle angegeben werden.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
update employees set
|
|
||||||
status = case when v_date <= current_date then 'Aktiv' else 'Geplant' end,
|
|
||||||
entry_date = v_date,
|
|
||||||
exit_date = null,
|
|
||||||
exit_reason = null
|
|
||||||
where id = v_employee_id;
|
|
||||||
|
|
||||||
insert into position_assignments (position_id, employee_id, valid_from)
|
|
||||||
values (v_position_id, v_employee_id, v_date);
|
|
||||||
|
|
||||||
insert into employee_history (employee_id, event_date, event_type, description)
|
|
||||||
values (v_employee_id, v_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || v_date);
|
|
||||||
|
|
||||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
|
||||||
values (auth.uid(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, 'Wiedereintritt am ' || v_date);
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
create or replace function record_karenz_return(payload jsonb)
|
|
||||||
returns void language plpgsql as $$
|
|
||||||
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;
|
|
||||||
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);
|
|
||||||
|
|
||||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
|
||||||
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || v_return_date);
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
create or replace function apply_due_pending_changes()
|
|
||||||
returns int language plpgsql security definer set search_path = public as $$
|
|
||||||
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)
|
|
||||||
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')::relationship_type,
|
|
||||||
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;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
commit;
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
-- Reste des Altmodells entfernen und die Planstellenpflege im OM-Modell
|
|
||||||
-- nachziehen.
|
|
||||||
--
|
|
||||||
-- Die Cut-over-Migration hat die Funktionen des Altmodells mit ihren damals
|
|
||||||
-- bekannten Signaturen entfernt. Ein Teil davon existierte zusätzlich in
|
|
||||||
-- einer jsonb-Variante und ist deshalb stehen geblieben — sichtbar daran,
|
|
||||||
-- dass delete_position und undo_reorg weiterhin in der PostgREST-Schnittstelle
|
|
||||||
-- auftauchen, obwohl die Tabellen, auf denen sie arbeiten, weg sind. Ein
|
|
||||||
-- Aufruf würde erst zur Laufzeit scheitern.
|
|
||||||
|
|
||||||
-- ═══ 1. Übriggebliebene Funktionen des Altmodells ════════════════
|
|
||||||
drop function if exists create_position(jsonb);
|
|
||||||
drop function if exists delete_position(jsonb);
|
|
||||||
drop function if exists delete_position(uuid);
|
|
||||||
drop function if exists staff_position_internally(jsonb);
|
|
||||||
drop function if exists apply_reorg(jsonb);
|
|
||||||
drop function if exists undo_reorg(jsonb);
|
|
||||||
drop function if exists undo_reorg(uuid);
|
|
||||||
|
|
||||||
-- ═══ 2. Reorganisations-Werkbank ═════════════════════════════════
|
|
||||||
-- Sie hat Teams und Abteilungen zwischen Bereichen verschoben — Objekte, die
|
|
||||||
-- es nicht mehr gibt. Im OM-Modell ist eine Reorganisation das Umhängen von
|
|
||||||
-- org_units.parent_id und braucht kein eigenes Szenario-Modell mehr.
|
|
||||||
alter table employee_history drop column if exists reorg_scenario_id;
|
|
||||||
alter table pending_org_changes drop column if exists reorg_scenario_id;
|
|
||||||
drop table if exists reorg_moves;
|
|
||||||
drop table if exists reorg_scenarios;
|
|
||||||
|
|
||||||
-- ═══ 3. Planstellen pflegen ══════════════════════════════════════
|
|
||||||
-- Die alte positions-Tabelle führte nur *offene* Stellen und war damit ein
|
|
||||||
-- eigenes Objekt neben der Person. Im OM-Modell hat jede Person eine
|
|
||||||
-- Planstelle, und eine offene Stelle ist schlicht eine unbesetzte. Anlegen
|
|
||||||
-- und Schliessen sind deshalb Operationen auf om_positions.
|
|
||||||
|
|
||||||
-- set search_path bei jeder Funktion: siehe die folgende Migration, dort steht
|
|
||||||
-- warum. Kurz: heute sind das INVOKER-Funktionen und der Pfad ist harmlos,
|
|
||||||
-- aber sobald eine davon einmal SECURITY DEFINER wird, wäre er es nicht mehr —
|
|
||||||
-- und daran denkt dann niemand.
|
|
||||||
create or replace function next_position_number()
|
|
||||||
returns text language sql stable
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
as $$
|
|
||||||
select '6' || lpad((coalesce(max(substring(position_number from 2)::bigint), 0) + 1)::text, 7, '0')
|
|
||||||
from om_positions
|
|
||||||
where position_number ~ '^6[0-9]{7}$';
|
|
||||||
$$;
|
|
||||||
|
|
||||||
comment on function next_position_number() is
|
|
||||||
'Nächste freie Planstellennummer im Nummernkreis 6xxxxxxx.';
|
|
||||||
|
|
||||||
create or replace function create_position(payload jsonb)
|
|
||||||
returns uuid language plpgsql
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
as $$
|
|
||||||
declare
|
|
||||||
v_org_unit_id uuid := (payload->>'org_unit_id')::uuid;
|
|
||||||
v_job_title text := nullif(trim(payload->>'job_title'), '');
|
|
||||||
v_is_chief boolean := coalesce((payload->>'is_chief')::boolean, false);
|
|
||||||
v_valid_from date := coalesce(nullif(payload->>'valid_from','')::date, current_date);
|
|
||||||
v_job_id uuid;
|
|
||||||
v_position_id uuid;
|
|
||||||
v_unit_name text;
|
|
||||||
begin
|
|
||||||
perform require_hr_admin();
|
|
||||||
|
|
||||||
select name into v_unit_name from org_units where id = v_org_unit_id;
|
|
||||||
if v_unit_name is null then
|
|
||||||
raise exception 'Die Organisationseinheit existiert nicht.';
|
|
||||||
end if;
|
|
||||||
if v_job_title is null then
|
|
||||||
raise exception 'Es muss eine Tätigkeit angegeben werden.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
-- Der Unique-Index würde das ebenfalls abfangen, aber mit einer Meldung,
|
|
||||||
-- die in der Oberfläche nichts erklärt.
|
|
||||||
if v_is_chief and exists (
|
|
||||||
select 1 from om_positions
|
|
||||||
where org_unit_id = v_org_unit_id and is_chief and valid_to is null
|
|
||||||
) then
|
|
||||||
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
-- Gleiche Tätigkeit, ein Katalogeintrag: sonst stehen "Schlosser:in" und
|
|
||||||
-- "Schlosser" nebeneinander und jede Auswertung nach Tätigkeit ist wertlos.
|
|
||||||
select id into v_job_id from jobs where lower(title) = lower(v_job_title);
|
|
||||||
if v_job_id is null then
|
|
||||||
insert into jobs (code, title)
|
|
||||||
values ('J' || lpad((select count(*) + 1 from jobs)::text, 4, '0'), v_job_title)
|
|
||||||
returning id into v_job_id;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
insert into om_positions (position_number, org_unit_id, job_id, is_chief, valid_from)
|
|
||||||
values (next_position_number(), v_org_unit_id, v_job_id, v_is_chief, v_valid_from)
|
|
||||||
returning id into v_position_id;
|
|
||||||
|
|
||||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
|
||||||
values (auth.uid(), current_actor_name(), 'Planstelle angelegt',
|
|
||||||
v_job_title || ' (' || v_unit_name || ')',
|
|
||||||
'Gültig ab ' || v_valid_from || case when v_is_chief then ', Leitung' else '' end);
|
|
||||||
|
|
||||||
return v_position_id;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
create or replace function delete_position(payload jsonb)
|
|
||||||
returns void language plpgsql
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
as $$
|
|
||||||
declare
|
|
||||||
v_position_id uuid := (payload->>'position_id')::uuid;
|
|
||||||
v_label text;
|
|
||||||
v_hat_historie boolean;
|
|
||||||
begin
|
|
||||||
perform require_hr_admin();
|
|
||||||
|
|
||||||
select j.title || ' (' || u.name || ')' into v_label
|
|
||||||
from om_positions p
|
|
||||||
join jobs j on j.id = p.job_id
|
|
||||||
join org_units u on u.id = p.org_unit_id
|
|
||||||
where p.id = v_position_id;
|
|
||||||
if v_label is null then
|
|
||||||
raise exception 'Die Planstelle existiert nicht.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
if exists (select 1 from position_assignments where position_id = v_position_id and valid_to is null) then
|
|
||||||
raise exception 'Die Planstelle ist besetzt und kann nicht entfernt werden.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
select exists (select 1 from position_assignments where position_id = v_position_id)
|
|
||||||
into v_hat_historie;
|
|
||||||
|
|
||||||
-- Eine Planstelle, auf der einmal jemand sass, wird geschlossen statt
|
|
||||||
-- gelöscht: sonst verschwindet mit ihr die Besetzungshistorie, und in der
|
|
||||||
-- Personalakte klafft eine Lücke.
|
|
||||||
if v_hat_historie then
|
|
||||||
update om_positions set valid_to = current_date where id = v_position_id;
|
|
||||||
else
|
|
||||||
delete from om_positions where id = v_position_id;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
|
||||||
values (auth.uid(), current_actor_name(),
|
|
||||||
case when v_hat_historie then 'Planstelle geschlossen' else 'Planstelle gelöscht' end,
|
|
||||||
v_label, null);
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
grant execute on function next_position_number() to anon, authenticated, service_role;
|
|
||||||
grant execute on function create_position(jsonb) to anon, authenticated, service_role;
|
|
||||||
grant execute on function delete_position(jsonb) to anon, authenticated, service_role;
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
-- search_path für alle eigenen Funktionen festnageln.
|
|
||||||
--
|
|
||||||
-- Der Supabase-Linter meldet 34 Funktionen mit „Function Search Path Mutable".
|
|
||||||
-- Nachgezählt sind das alles SECURITY-INVOKER-Funktionen; die vier
|
|
||||||
-- SECURITY-DEFINER-Funktionen (is_hr_user, current_hr_user_id,
|
|
||||||
-- apply_due_pending_changes, fn_track_employee_assignment) setzen den Pfad
|
|
||||||
-- längst. Deshalb steht im Advisor auch 0 errors.
|
|
||||||
--
|
|
||||||
-- Warum das trotzdem behoben wird:
|
|
||||||
--
|
|
||||||
-- Der Angriff braucht SECURITY DEFINER. Wer in einem Schema, das im
|
|
||||||
-- search_path früher liegt, eine eigene Tabelle `employees` anlegt, bringt
|
|
||||||
-- eine unqualifiziert schreibende Funktion dazu, auf die untergeschobene
|
|
||||||
-- zuzugreifen — mit den Rechten der Eigentümerin der Funktion. Bei INVOKER
|
|
||||||
-- läuft alles mit den Rechten der aufrufenden Person, es gibt also nichts zu
|
|
||||||
-- gewinnen, und die RLS-Policies greifen unverändert.
|
|
||||||
--
|
|
||||||
-- Zur Lücke wird die Warnung erst, wenn eine dieser Funktionen später auf
|
|
||||||
-- SECURITY DEFINER umgestellt wird, etwa weil eine Mutation an RLS vorbei
|
|
||||||
-- schreiben muss. In dem Moment denkt niemand mehr an den search_path.
|
|
||||||
-- Einmal festnageln räumt die Falle weg und ändert kein Verhalten.
|
|
||||||
--
|
|
||||||
-- `pg_temp` steht ausdrücklich am Ende: ohne die Angabe durchsucht Postgres
|
|
||||||
-- das temporäre Schema *zuerst*, und dort darf jede Sitzung anlegen, was sie
|
|
||||||
-- will.
|
|
||||||
|
|
||||||
do $$
|
|
||||||
declare
|
|
||||||
v_func record;
|
|
||||||
v_count int := 0;
|
|
||||||
begin
|
|
||||||
for v_func in
|
|
||||||
select p.oid::regprocedure as signature
|
|
||||||
from pg_proc p
|
|
||||||
join pg_namespace n on n.oid = p.pronamespace
|
|
||||||
where n.nspname = 'public'
|
|
||||||
-- Nur Funktionen, keine Prozeduren oder Aggregate.
|
|
||||||
and p.prokind = 'f'
|
|
||||||
-- Erweiterungen gehören uns nicht: pg_trgm legt show_trgm und show_limit
|
|
||||||
-- in public ab. Daran zu drehen bricht bei der nächsten Aktualisierung
|
|
||||||
-- der Erweiterung oder wird stillschweigend zurückgesetzt.
|
|
||||||
and not exists (
|
|
||||||
select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e'
|
|
||||||
)
|
|
||||||
-- Bereits gesetzte nicht anfassen: die vier DEFINER-Funktionen stehen
|
|
||||||
-- auf `search_path = public` und sollen so bleiben.
|
|
||||||
and not exists (
|
|
||||||
select 1 from unnest(coalesce(p.proconfig, '{}')) c where c like 'search_path=%'
|
|
||||||
)
|
|
||||||
loop
|
|
||||||
execute format('alter function %s set search_path = public, pg_temp', v_func.signature);
|
|
||||||
v_count := v_count + 1;
|
|
||||||
end loop;
|
|
||||||
|
|
||||||
raise notice 'search_path festgenagelt für % Funktion(en)', v_count;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- Gegenprobe: danach darf in public keine eigene Funktion ohne search_path
|
|
||||||
-- mehr stehen. Schlägt das an, hat die Schleife oben etwas übersehen — besser
|
|
||||||
-- hier, als es im Advisor stehen zu lassen.
|
|
||||||
do $$
|
|
||||||
declare v_offen int;
|
|
||||||
begin
|
|
||||||
select count(*) into v_offen
|
|
||||||
from pg_proc p
|
|
||||||
join pg_namespace n on n.oid = p.pronamespace
|
|
||||||
where n.nspname = 'public'
|
|
||||||
and p.prokind = 'f'
|
|
||||||
and not exists (select 1 from pg_depend d where d.objid = p.oid and d.deptype = 'e')
|
|
||||||
and not exists (select 1 from unnest(coalesce(p.proconfig, '{}')) c where c like 'search_path=%');
|
|
||||||
|
|
||||||
if v_offen > 0 then
|
|
||||||
raise exception 'Es stehen noch % Funktion(en) ohne search_path in public.', v_offen;
|
|
||||||
end if;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
-- Ausführungsrechte auf den SECURITY-DEFINER-Funktionen zurechtrücken.
|
|
||||||
--
|
|
||||||
-- Der Advisor meldet alle vier als „Public Can Execute" und „Signed-In Users
|
|
||||||
-- Can Execute". Das ist nicht bei allen vieren dasselbe Problem — nachgemessen
|
|
||||||
-- mit dem anon-Schlüssel gegen die laufende Datenbank:
|
|
||||||
--
|
|
||||||
-- anon.rpc(is_hr_user) -> false
|
|
||||||
-- anon.rpc(current_hr_user_id) -> null
|
|
||||||
-- anon.rpc(apply_due_pending_changes) -> 0 ← das ist der Befund
|
|
||||||
--
|
|
||||||
-- Nur der dritte ist einer.
|
|
||||||
|
|
||||||
-- ── Bleibt offen, und zwar mit Absicht ───────────────────────────
|
|
||||||
--
|
|
||||||
-- is_hr_user() und current_hr_user_id() werden *aus den RLS-Policies heraus*
|
|
||||||
-- aufgerufen. Ein Policy-Ausdruck wird mit den Rechten der abfragenden Rolle
|
|
||||||
-- ausgewertet; ohne EXECUTE für anon und authenticated scheitert damit jede
|
|
||||||
-- Abfrage auf jeder Tabelle mit „permission denied for function". Der Entzug
|
|
||||||
-- würde die Anwendung vollständig lahmlegen.
|
|
||||||
--
|
|
||||||
-- Preisgegeben wird dabei nichts: beide nehmen keine Argumente und beantworten
|
|
||||||
-- ausschliesslich eine Frage über die aufrufende Person selbst. Wer nicht
|
|
||||||
-- angemeldet ist, bekommt false beziehungsweise null — siehe Messung oben.
|
|
||||||
|
|
||||||
-- ── Wird entzogen ────────────────────────────────────────────────
|
|
||||||
--
|
|
||||||
-- apply_due_pending_changes() wendet vorgemerkte Versetzungen, Beförderungen
|
|
||||||
-- und Abwesenheiten an, sobald ihr Datum erreicht ist. Es ist SECURITY
|
|
||||||
-- DEFINER, umgeht also RLS, und war bis hierher ohne Anmeldung aufrufbar — der
|
|
||||||
-- anon-Schlüssel steht im ausgelieferten Browser-Bündel.
|
|
||||||
--
|
|
||||||
-- Der Schaden wäre begrenzt, weil nur ohnehin fällige Änderungen angewandt
|
|
||||||
-- werden. Aber es ist ein Schreibpfad, den Fremde auslösen können, und er
|
|
||||||
-- macht das Geheimnis der Cron-Route (app/api/cron/apply-pending-changes)
|
|
||||||
-- wirkungslos.
|
|
||||||
--
|
|
||||||
-- Diese Route ist der einzige Aufrufer und benutzt createAdminClient(), also
|
|
||||||
-- die service_role — der Entzug für anon und authenticated bricht sie nicht.
|
|
||||||
revoke execute on function apply_due_pending_changes() from anon, authenticated;
|
|
||||||
|
|
||||||
-- rls_auto_enable() stammt nicht aus diesen Migrationen und wird von der
|
|
||||||
-- Anwendung nirgends aufgerufen. Was sie tut, ist von hier aus nicht
|
|
||||||
-- feststellbar; eine Funktion, die RLS umschaltet und ohne Anmeldung
|
|
||||||
-- aufrufbar ist, wäre allerdings ernst. Der Entzug ist risikolos, weil kein
|
|
||||||
-- Aufrufer existiert — und falls doch jemand sie braucht, meldet er sich mit
|
|
||||||
-- einer klaren Fehlermeldung statt still etwas zu verstellen.
|
|
||||||
do $$
|
|
||||||
begin
|
|
||||||
if exists (
|
|
||||||
select 1 from pg_proc p
|
|
||||||
join pg_namespace n on n.oid = p.pronamespace
|
|
||||||
where n.nspname = 'public' and p.proname = 'rls_auto_enable'
|
|
||||||
) then
|
|
||||||
execute 'revoke execute on function public.rls_auto_enable() from anon, authenticated';
|
|
||||||
end if;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- ── Was bewusst *nicht* passiert ─────────────────────────────────
|
|
||||||
--
|
|
||||||
-- „Extension in Public" (pg_trgm) bleibt stehen. Die Erweiterung trägt die
|
|
||||||
-- Operatorklasse gin_trgm_ops, auf der zwei GIN-Indizes auf employees liegen
|
|
||||||
-- (20260714120400_performance_indexes.sql). Ein Schemawechsel müsste die
|
|
||||||
-- Indizes und jeden search_path mitziehen, der sie erreichen soll — gerade
|
|
||||||
-- jetzt, wo jede Funktion auf `public, pg_temp` festgenagelt ist. Das ist
|
|
||||||
-- Aufwand und Risiko für einen Hinweis, der keine Rechteausweitung beschreibt,
|
|
||||||
-- sondern eine Konvention.
|
|
||||||
--
|
|
||||||
-- „Leaked Password Protection Disabled" ist gegenstandslos: die
|
|
||||||
-- Passwort-Anmeldung ist abgeschaltet. Eine Anmeldung mit E-Mail und Passwort
|
|
||||||
-- gegen die API antwortet mit `email_provider_disabled` (422). Es gibt kein
|
|
||||||
-- Passwort, dessen Kompromittierung geprüft werden könnte.
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
-- Schritt 1 auf dem Weg weg von Supabase: eigene Benutzertabelle und ein
|
|
||||||
-- eigener Sitzungskontext.
|
|
||||||
--
|
|
||||||
-- Ziel ist ein Schema, das auf jedem PostgreSQL ab 15 läuft — Azure Flexible
|
|
||||||
-- Server, RDS, Cloud SQL, eigenes Blech. Heute hängt genau eine Sache an
|
|
||||||
-- Supabase: `auth.uid()`, die Kennung der angemeldeten Person. Sie steckt in
|
|
||||||
-- 72 Zeilen SQL, aber für die Absicherung zählt nur eine Stelle —
|
|
||||||
-- is_hr_user(), das alle 58 RLS-Policies aufrufen.
|
|
||||||
--
|
|
||||||
-- Diese Migration ist bewusst **additiv und beidseitig lauffähig**: die
|
|
||||||
-- Anwendung läuft danach unverändert auf Supabase weiter, während die neue
|
|
||||||
-- Zugriffsschicht daneben entsteht. Ein Umbau, der beide Enden gleichzeitig
|
|
||||||
-- bewegt, lässt sich nicht testen.
|
|
||||||
|
|
||||||
-- ═══ 1. Sitzungskontext ══════════════════════════════════════════
|
|
||||||
-- Wer gerade angemeldet ist, kommt künftig aus einer Sitzungsvariablen, die
|
|
||||||
-- die Zugriffsschicht **transaktionslokal** setzt (siehe lib/db).
|
|
||||||
--
|
|
||||||
-- Warum plpgsql und nicht `language sql`: eine SQL-Funktion wird beim Anlegen
|
|
||||||
-- geparst, und `auth.uid()` existiert auf einem gewöhnlichen PostgreSQL
|
|
||||||
-- nicht — die Funktion liesse sich dort gar nicht erst erzeugen. plpgsql löst
|
|
||||||
-- den Aufruf erst zur Laufzeit auf, und der Ausnahmeblock fängt die fehlende
|
|
||||||
-- Funktion ab. Genau das macht diese Migration auf beiden Systemen anwendbar.
|
|
||||||
create or replace function app_current_user_id()
|
|
||||||
returns uuid
|
|
||||||
language plpgsql
|
|
||||||
stable
|
|
||||||
security definer
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
as $$
|
|
||||||
declare
|
|
||||||
v_id uuid;
|
|
||||||
begin
|
|
||||||
-- Vorrang hat der eigene Kontext. `true` als zweites Argument heisst:
|
|
||||||
-- fehlt die Variable, kommt null statt eines Fehlers.
|
|
||||||
v_id := nullif(current_setting('app.user_id', true), '')::uuid;
|
|
||||||
if v_id is not null then
|
|
||||||
return v_id;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
-- Übergangsweise: solange die Anmeldung noch über GoTrue läuft. Fällt in
|
|
||||||
-- der Abschlussmigration weg, zusammen mit den Fremdschlüsseln auf
|
|
||||||
-- auth.users.
|
|
||||||
begin
|
|
||||||
execute 'select auth.uid()' into v_id;
|
|
||||||
exception
|
|
||||||
when undefined_function or invalid_schema_name or undefined_table then
|
|
||||||
v_id := null;
|
|
||||||
end;
|
|
||||||
return v_id;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
comment on function app_current_user_id() is
|
|
||||||
'Kennung der angemeldeten Person: erst app.user_id aus der Sitzung, ersatzweise auth.uid(). Der zweite Zweig ist Übergang.';
|
|
||||||
|
|
||||||
-- Zugeteilt wird nur an Rollen, die es auch gibt. Auf einem gewöhnlichen
|
|
||||||
-- PostgreSQL existieren anon/authenticated/service_role nicht, und ein
|
|
||||||
-- `grant` auf eine unbekannte Rolle bricht die Migration ab — dieselbe Datei
|
|
||||||
-- liefe dort also nicht. Genau das soll sie aber.
|
|
||||||
do $$
|
|
||||||
declare r text;
|
|
||||||
begin
|
|
||||||
foreach r in array array['anon', 'authenticated', 'service_role'] loop
|
|
||||||
if exists (select 1 from pg_roles where rolname = r) then
|
|
||||||
execute format('grant execute on function app_current_user_id() to %I', r);
|
|
||||||
end if;
|
|
||||||
end loop;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- ═══ 2. Benutzertabelle ══════════════════════════════════════════
|
|
||||||
-- Tritt an die Stelle von auth.users. Die neun Fremdschlüssel, die heute
|
|
||||||
-- dorthin zeigen, wandern in der Abschlussmigration hierher.
|
|
||||||
create table if not exists app_users (
|
|
||||||
id uuid primary key default gen_random_uuid(),
|
|
||||||
-- Die `oid` aus dem Entra-Token. Unveränderlich, anders als die E-Mail:
|
|
||||||
-- eine Namensänderung darf nicht zu einem neuen Konto führen.
|
|
||||||
external_id text not null unique,
|
|
||||||
email text not null,
|
|
||||||
full_name text,
|
|
||||||
created_at timestamptz not null default now(),
|
|
||||||
last_seen_at timestamptz
|
|
||||||
);
|
|
||||||
|
|
||||||
comment on table app_users is
|
|
||||||
'Ersetzt auth.users. external_id ist die oid des Identitätsanbieters, nicht die E-Mail.';
|
|
||||||
|
|
||||||
create index if not exists app_users_email_idx on app_users (lower(email));
|
|
||||||
|
|
||||||
alter table app_users enable row level security;
|
|
||||||
|
|
||||||
-- Sich selbst sehen darf jede:r Angemeldete; alles andere ist HR-Sache.
|
|
||||||
-- Ohne diese Policy käme die Anmeldung nicht an die eigene Zeile.
|
|
||||||
drop policy if exists "app_users_select_own" on app_users;
|
|
||||||
create policy "app_users_select_own" on app_users
|
|
||||||
for select using (id = app_current_user_id() or is_hr_user());
|
|
||||||
|
|
||||||
do $$
|
|
||||||
begin
|
|
||||||
if exists (select 1 from pg_roles where rolname = 'anon') then
|
|
||||||
execute 'grant select on table app_users to anon';
|
|
||||||
end if;
|
|
||||||
if exists (select 1 from pg_roles where rolname = 'authenticated') then
|
|
||||||
execute 'grant select on table app_users to authenticated';
|
|
||||||
end if;
|
|
||||||
if exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
||||||
execute 'grant all on table app_users to service_role';
|
|
||||||
end if;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- ═══ 3. Die eine Brücke umlegen ══════════════════════════════════
|
|
||||||
-- Ab hier fragt die Absicherung nicht mehr Supabase, sondern den eigenen
|
|
||||||
-- Kontext. Die 58 Policies bleiben Wort für Wort unverändert — sie rufen
|
|
||||||
-- weiterhin is_hr_user() auf und merken davon nichts.
|
|
||||||
create or replace function is_hr_user()
|
|
||||||
returns boolean
|
|
||||||
language sql
|
|
||||||
security definer
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
stable
|
|
||||||
as $$
|
|
||||||
select exists (
|
|
||||||
select 1 from profiles p
|
|
||||||
where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true
|
|
||||||
);
|
|
||||||
$$;
|
|
||||||
|
|
||||||
create or replace function current_hr_user_id()
|
|
||||||
returns uuid
|
|
||||||
language sql
|
|
||||||
security definer
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
stable
|
|
||||||
as $$
|
|
||||||
select p.id from profiles p
|
|
||||||
where p.id = app_current_user_id() and p.role = 'hr' and p.is_active = true;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
create or replace function current_actor_name()
|
|
||||||
returns text
|
|
||||||
language sql
|
|
||||||
stable
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
as $$
|
|
||||||
select coalesce(p.full_name, p.email, 'Unbekannt')
|
|
||||||
from profiles p where p.id = app_current_user_id();
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- ═══ 4. Die fünf Policies mit direktem auth.uid() ════════════════
|
|
||||||
-- Die übrigen 53 laufen über is_hr_user() und brauchen nichts.
|
|
||||||
drop policy if exists "profiles_select_own" on profiles;
|
|
||||||
create policy "profiles_select_own" on profiles
|
|
||||||
for select using (app_current_user_id() = id);
|
|
||||||
|
|
||||||
-- Die Namen stammen aus 20260714120000_hr_only_access.sql: „_owner", nicht
|
|
||||||
-- „_own". Mit dem falschen Namen bricht die Migration bei create policy ab.
|
|
||||||
drop policy if exists "hire_drafts_owner" on hire_drafts;
|
|
||||||
create policy "hire_drafts_owner" on hire_drafts
|
|
||||||
for all
|
|
||||||
using (created_by = app_current_user_id() and is_hr_user())
|
|
||||||
with check (created_by = app_current_user_id() and is_hr_user());
|
|
||||||
|
|
||||||
drop policy if exists "saved_reports_owner" on saved_reports;
|
|
||||||
create policy "saved_reports_owner" on saved_reports
|
|
||||||
for all
|
|
||||||
using (created_by = app_current_user_id() and is_hr_user())
|
|
||||||
with check (created_by = app_current_user_id() and is_hr_user());
|
|
||||||
|
|
||||||
-- ═══ 5. Gegenprobe ═══════════════════════════════════════════════
|
|
||||||
-- Ohne Kontext und ohne Anmeldung darf is_hr_user() nicht wahr sein. Das
|
|
||||||
-- klingt selbstverständlich und ist genau der Fehler, der eine ganze
|
|
||||||
-- Datenbank öffnet.
|
|
||||||
do $$
|
|
||||||
begin
|
|
||||||
perform set_config('app.user_id', '', true);
|
|
||||||
if is_hr_user() then
|
|
||||||
raise exception 'is_hr_user() liefert ohne Sitzungskontext true — Abbruch.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
perform set_config('app.user_id', gen_random_uuid()::text, true);
|
|
||||||
if is_hr_user() then
|
|
||||||
raise exception 'is_hr_user() liefert für eine unbekannte Kennung true — Abbruch.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
-- Aufräumen: die Einstellung gilt bis zum Ende dieser Transaktion, und
|
|
||||||
-- was danach in derselben Sitzung läuft, soll sie nicht erben.
|
|
||||||
perform set_config('app.user_id', '', true);
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
-- Schritt 2: die Anmeldung braucht einen Weg, ihre Zeile in app_users
|
|
||||||
-- anzulegen — bevor es einen Sitzungskontext gibt.
|
|
||||||
--
|
|
||||||
-- Das ist das Henne-Ei-Problem jeder eigenen Anmeldung: app.user_id kann erst
|
|
||||||
-- gesetzt werden, wenn die Kennung feststeht, und die entsteht genau hier.
|
|
||||||
-- Bisher löste ein Dienstschlüssel mit BYPASSRLS solche Fälle. Den gibt es
|
|
||||||
-- nicht mehr, und er soll auch nicht zurückkommen — eine Verbindung, die
|
|
||||||
-- alles darf, ist für einen einzigen Schreibvorgang ein zu grosser Hebel.
|
|
||||||
--
|
|
||||||
-- Stattdessen: eine SECURITY-DEFINER-Funktion mit genau einer Befugnis.
|
|
||||||
-- Sie schreibt in app_users und liest lesend in profiles — sonst nichts. Wer
|
|
||||||
-- sie aufruft, bekommt eine UUID zurück und sonst keine Auskunft.
|
|
||||||
create or replace function app_upsert_user(
|
|
||||||
p_external_id text,
|
|
||||||
p_email text,
|
|
||||||
p_full_name text
|
|
||||||
)
|
|
||||||
returns uuid
|
|
||||||
language plpgsql
|
|
||||||
security definer
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
as $$
|
|
||||||
declare
|
|
||||||
v_id uuid;
|
|
||||||
begin
|
|
||||||
if p_external_id is null or btrim(p_external_id) = '' then
|
|
||||||
raise exception 'Externe Kennung fehlt.';
|
|
||||||
end if;
|
|
||||||
if p_email is null or btrim(p_email) = '' then
|
|
||||||
raise exception 'E-Mail-Adresse fehlt.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
-- Bekanntes Konto: nur nachziehen, was sich beim Anbieter geändert haben
|
|
||||||
-- kann. Die Kennung bleibt, auch wenn Name oder Adresse wechseln — daran
|
|
||||||
-- hängen Notizen, Entwürfe und Protokolleinträge.
|
|
||||||
update app_users
|
|
||||||
set email = p_email,
|
|
||||||
full_name = coalesce(p_full_name, full_name),
|
|
||||||
last_seen_at = now()
|
|
||||||
where external_id = p_external_id
|
|
||||||
returning id into v_id;
|
|
||||||
|
|
||||||
if v_id is not null then
|
|
||||||
return v_id;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
-- Erstanmeldung. Gibt es zu dieser Adresse bereits ein Profil, wird dessen
|
|
||||||
-- Kennung übernommen statt einer neuen: profiles.id ist heute die
|
|
||||||
-- auth.users.id, und neun Fremdschlüssel zeigen darauf. Eine frisch
|
|
||||||
-- vergebene UUID würde die Person von ihrer eigenen Vorgeschichte trennen
|
|
||||||
-- — sie wäre angemeldet, hätte aber weder Rolle noch Freischaltung.
|
|
||||||
--
|
|
||||||
-- Der Abgleich über die Adresse ist hier vertretbar und sonst nirgends:
|
|
||||||
-- die Adresse kommt aus einem von Entra ausgestellten Token, nicht aus
|
|
||||||
-- einem Formular. Wer sie behauptet, hat sie bereits bewiesen.
|
|
||||||
select p.id into v_id
|
|
||||||
from profiles p
|
|
||||||
where lower(p.email) = lower(p_email)
|
|
||||||
limit 1;
|
|
||||||
|
|
||||||
v_id := coalesce(v_id, gen_random_uuid());
|
|
||||||
|
|
||||||
begin
|
|
||||||
insert into app_users (id, external_id, email, full_name, last_seen_at)
|
|
||||||
values (v_id, p_external_id, p_email, p_full_name, now());
|
|
||||||
exception
|
|
||||||
when unique_violation then
|
|
||||||
-- Zwei gleichzeitige Erstanmeldungen desselben Kontos. Die zweite
|
|
||||||
-- findet die Zeile, die die erste gerade angelegt hat.
|
|
||||||
select id into v_id from app_users where external_id = p_external_id;
|
|
||||||
if v_id is null then
|
|
||||||
raise;
|
|
||||||
end if;
|
|
||||||
end;
|
|
||||||
|
|
||||||
return v_id;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
comment on function app_upsert_user(text, text, text) is
|
|
||||||
'Legt die app_users-Zeile zur Erstanmeldung an und liefert die Kennung. Übernimmt bei bekannter E-Mail die vorhandene profiles.id.';
|
|
||||||
|
|
||||||
-- app_users trägt RLS und hat bewusst keine Schreib-Policy: die Anwendung
|
|
||||||
-- kommt an die Tabelle nur durch diese Funktion. Ein Fehler im Anwendungscode
|
|
||||||
-- kann dort also nichts anlegen, ändern oder löschen.
|
|
||||||
do $$
|
|
||||||
declare r text;
|
|
||||||
begin
|
|
||||||
foreach r in array array['anon', 'authenticated', 'service_role'] loop
|
|
||||||
if exists (select 1 from pg_roles where rolname = r) then
|
|
||||||
execute format('grant execute on function app_upsert_user(text, text, text) to %I', r);
|
|
||||||
end if;
|
|
||||||
end loop;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
|
||||||
-- Zweimal dieselbe externe Kennung muss dieselbe UUID ergeben. Wäre es nicht
|
|
||||||
-- so, bekäme jede Anmeldung ein neues Konto und niemand behielte seine
|
|
||||||
-- Rolle — ein Fehler, der sich erst Wochen später als „meine Notizen sind
|
|
||||||
-- weg" zeigt.
|
|
||||||
do $$
|
|
||||||
declare
|
|
||||||
v_first uuid;
|
|
||||||
v_second uuid;
|
|
||||||
v_probe text := 'probe-' || gen_random_uuid()::text;
|
|
||||||
begin
|
|
||||||
v_first := app_upsert_user(v_probe, v_probe || '@example.invalid', 'Probe');
|
|
||||||
v_second := app_upsert_user(v_probe, v_probe || '@example.invalid', 'Probe');
|
|
||||||
|
|
||||||
if v_first is distinct from v_second then
|
|
||||||
raise exception 'app_upsert_user() vergibt bei zweiter Anmeldung eine neue Kennung — Abbruch.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
delete from app_users where id = v_first;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
-- Die Anwendungsrolle muss die Personalnummern-Sequenz fortschreiben dürfen.
|
|
||||||
--
|
|
||||||
-- Hintergrund: employees.personnel_number ist GENERATED ALWAYS AS IDENTITY.
|
|
||||||
-- Der Massenimport übernimmt die Nummern aus der Quelldatei — sie stehen auf
|
|
||||||
-- Lohnzetteln, in Akten und auf Ausweisen, ein Import darf sie nicht neu
|
|
||||||
-- vergeben — und schreibt danach den Zähler auf das neue Maximum.
|
|
||||||
--
|
|
||||||
-- Ohne diesen Schritt vergäbe die Datenbank bei der nächsten Neueinstellung
|
|
||||||
-- eine Nummer, die der Import bereits verbraucht hat. Der eindeutige Index
|
|
||||||
-- weist sie ab, und zwar erst Wochen später beim ersten Eintritt nach der
|
|
||||||
-- Übernahme — weit weg von der Ursache.
|
|
||||||
--
|
|
||||||
-- `setval()` verlangt UPDATE auf der Sequenz. `usage, select` reicht nicht;
|
|
||||||
-- genau daran ist der erste Durchstich gescheitert.
|
|
||||||
do $$
|
|
||||||
declare
|
|
||||||
v_sequenz text := pg_get_serial_sequence('public.employees', 'personnel_number');
|
|
||||||
r text;
|
|
||||||
begin
|
|
||||||
if v_sequenz is null then
|
|
||||||
raise exception 'Sequenz zu employees.personnel_number nicht gefunden.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
-- Nur an Rollen, die es gibt: dieselbe Datei soll auf einem gewöhnlichen
|
|
||||||
-- PostgreSQL ohne die Supabase-Rollen laufen.
|
|
||||||
foreach r in array array['alpenwerk_app', 'authenticated', 'service_role'] loop
|
|
||||||
if exists (select 1 from pg_roles where rolname = r) then
|
|
||||||
execute format('grant usage, select, update on sequence %s to %I', v_sequenz, r);
|
|
||||||
end if;
|
|
||||||
end loop;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- Damit künftige Sequenzen in diesem Schema dieselben Rechte bekommen und
|
|
||||||
-- der nächste Import nicht an derselben Stelle stehenbleibt.
|
|
||||||
do $$
|
|
||||||
begin
|
|
||||||
if exists (select 1 from pg_roles where rolname = 'alpenwerk_app') then
|
|
||||||
execute 'alter default privileges in schema public grant usage, select, update on sequences to alpenwerk_app';
|
|
||||||
end if;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
-- Das Protokoll soll sagen, *was* sich geändert hat, nicht nur *welches Feld*.
|
|
||||||
--
|
|
||||||
-- Bisher stand im Audit-Log „Adresse, wirksam ab 30.07.2026". Damit lässt
|
|
||||||
-- sich nicht nachvollziehen, was vorher dort stand — und genau das ist die
|
|
||||||
-- Frage, die man einem Personalprotokoll stellt.
|
|
||||||
--
|
|
||||||
-- Die Werte sind im Moment der Änderung beide vorhanden: v_old trägt den
|
|
||||||
-- alten Datensatz, die Nutzlast den neuen. Sie wurden nur nicht behalten.
|
|
||||||
--
|
|
||||||
-- **Rückwirkend geht das nicht.** Für die bestehenden Einträge wurde
|
|
||||||
-- Vorher/Nachher nie erfasst; sie bleiben, wie sie sind.
|
|
||||||
|
|
||||||
alter table audit_log add column if not exists changes jsonb;
|
|
||||||
|
|
||||||
comment on column audit_log.changes is
|
|
||||||
'Feldweise Änderungen als [{feld, vorher, nachher}]. Null bei Einträgen von vor dieser Migration.';
|
|
||||||
|
|
||||||
-- ═══ Hilfsfunktion ═══════════════════════════════════════════════
|
|
||||||
-- Hängt eine Änderung an, wenn sich der Wert wirklich unterscheidet.
|
|
||||||
--
|
|
||||||
-- Verglichen wird über coalesce auf Leerstring: null und '' sind in diesem
|
|
||||||
-- Schema beide „nicht gesetzt", und ein Wechsel zwischen beiden ist keine
|
|
||||||
-- Änderung, die jemanden interessiert.
|
|
||||||
create or replace function app_aenderung(p_liste jsonb, p_feld text, p_vorher text, p_nachher text)
|
|
||||||
returns jsonb
|
|
||||||
language sql
|
|
||||||
immutable
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
as $$
|
|
||||||
select case
|
|
||||||
when coalesce(p_vorher, '') = coalesce(p_nachher, '') then p_liste
|
|
||||||
else p_liste || jsonb_build_object('feld', p_feld, 'vorher', p_vorher, 'nachher', p_nachher)
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- Die Feldnamen aus einer Änderungsliste — für die Kurzfassung in `details`,
|
|
||||||
-- damit bestehende Ansichten unverändert weiterlaufen.
|
|
||||||
create or replace function app_aenderungsfelder(p_liste jsonb)
|
|
||||||
returns text
|
|
||||||
language sql
|
|
||||||
immutable
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
as $$
|
|
||||||
select string_agg(x->>'feld', ', ') from jsonb_array_elements(coalesce(p_liste, '[]'::jsonb)) x;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- ═══ Stammdaten- und Vertragsänderung ════════════════════════════
|
|
||||||
create or replace function change_employee_data(payload jsonb)
|
|
||||||
returns void
|
|
||||||
language plpgsql
|
|
||||||
set search_path = 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 ? '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_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),
|
|
||||||
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)
|
|
||||||
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)
|
|
||||||
values (v_employee_id, v_effective_date, 'Stammdatenänderung',
|
|
||||||
'Geänderte Felder: ' || app_aenderungsfelder(v_person_changes) || ', wirksam ab ' || v_effective_date);
|
|
||||||
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)
|
|
||||||
values (v_employee_id, v_effective_date, 'Vertragsänderung',
|
|
||||||
'Geänderte Felder: ' || app_aenderungsfelder(v_contract_changes) || ', wirksam ab ' || v_effective_date);
|
|
||||||
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$;
|
|
||||||
|
|
||||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
|
||||||
-- Die Hilfsfunktion muss Gleiches übergehen und Ungleiches behalten —
|
|
||||||
-- inklusive des Falls null gegen Leerstring, der sonst als Änderung im
|
|
||||||
-- Protokoll landet und niemandem etwas sagt.
|
|
||||||
do $$
|
|
||||||
declare v jsonb;
|
|
||||||
begin
|
|
||||||
v := app_aenderung('[]'::jsonb, 'Ort', 'Wien', 'Wien');
|
|
||||||
if jsonb_array_length(v) <> 0 then raise exception 'app_aenderung() meldet eine Änderung, wo keine ist.'; end if;
|
|
||||||
|
|
||||||
v := app_aenderung('[]'::jsonb, 'Ort', null, '');
|
|
||||||
if jsonb_array_length(v) <> 0 then raise exception 'app_aenderung() wertet null gegen Leerstring als Änderung.'; end if;
|
|
||||||
|
|
||||||
v := app_aenderung('[]'::jsonb, 'Ort', 'Wien', 'Graz');
|
|
||||||
if jsonb_array_length(v) <> 1 or v->0->>'vorher' <> 'Wien' or v->0->>'nachher' <> 'Graz' then
|
|
||||||
raise exception 'app_aenderung() hält Vorher/Nachher nicht fest.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
if app_aenderungsfelder(v) <> 'Ort' then raise exception 'app_aenderungsfelder() liefert die Feldnamen nicht.'; end if;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
-- Zwei Funktionen casten auf Typen, die es nicht mehr gibt.
|
|
||||||
--
|
|
||||||
-- hire_employee ::weekday[]
|
|
||||||
-- apply_due_pending_changes ::relationship_type
|
|
||||||
--
|
|
||||||
-- Beide Typen waren einmal Aufzählungen und wurden später durch `text` mit
|
|
||||||
-- einer CHECK-Bedingung ersetzt (chk_work_days_valid). Die Funktionen wurden
|
|
||||||
-- dabei nicht nachgezogen.
|
|
||||||
--
|
|
||||||
-- PL/pgSQL löst Typen in eingebetteten SQL-Anweisungen erst beim **Ausführen**
|
|
||||||
-- auf. Die Funktionen liessen sich deshalb anlegen und scheitern erst im
|
|
||||||
-- Betrieb — jede Neueinstellung mit „type weekday[] does not exist", und der
|
|
||||||
-- nächtliche Lauf, sobald eine fällige Angehörigen-Änderung darin vorkommt.
|
|
||||||
-- Genau deshalb hat es niemand beim Einspielen gemerkt.
|
|
||||||
--
|
|
||||||
-- Ersetzt wird gezielt der Cast an der **aktuellen** Definition, statt beide
|
|
||||||
-- Funktionen abzuschreiben: 165 Zeilen fremden Code neu zu tippen, um zwei
|
|
||||||
-- Wörter zu ändern, ist die grössere Fehlerquelle.
|
|
||||||
|
|
||||||
create or replace function app_cast_ersetzen(p_funktion text, p_alt text, p_neu text)
|
|
||||||
returns void
|
|
||||||
language plpgsql
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
as $$
|
|
||||||
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 = p_funktion
|
|
||||||
limit 1;
|
|
||||||
|
|
||||||
if v_def is null then
|
|
||||||
raise exception 'Funktion %() nicht gefunden.', p_funktion;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
if position(p_alt in v_def) = 0 then
|
|
||||||
raise notice '%(): % kommt nicht (mehr) vor — nichts zu tun.', p_funktion, p_alt;
|
|
||||||
return;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
execute replace(v_def, p_alt, p_neu);
|
|
||||||
raise notice '%(): % -> %', p_funktion, p_alt, p_neu;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
select app_cast_ersetzen('hire_employee', '::weekday[]', '::text[]');
|
|
||||||
select app_cast_ersetzen('apply_due_pending_changes', '::relationship_type', '::text');
|
|
||||||
|
|
||||||
-- Das Werkzeug wird nicht aufbewahrt: eine Funktion, die beliebigen Text in
|
|
||||||
-- eine Funktionsdefinition schreibt und ausführt, soll nicht dauerhaft im
|
|
||||||
-- Schema stehen.
|
|
||||||
drop function app_cast_ersetzen(text, text, text);
|
|
||||||
|
|
||||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
|
||||||
-- Keine Funktion im Schema darf mehr auf einen Typ casten, den es nicht
|
|
||||||
-- gibt. Das prüft nicht nur die zwei bekannten Stellen, sondern schliesst
|
|
||||||
-- aus, dass beim Ersetzen eine dritte übersehen wurde.
|
|
||||||
do $$
|
|
||||||
declare
|
|
||||||
r record;
|
|
||||||
v_treffer text;
|
|
||||||
begin
|
|
||||||
for r in
|
|
||||||
select p.proname, pg_get_functiondef(p.oid) as def
|
|
||||||
from pg_proc p
|
|
||||||
join pg_namespace n on n.oid = p.pronamespace
|
|
||||||
where n.nspname = 'public' and p.prokind = 'f'
|
|
||||||
loop
|
|
||||||
for v_treffer in
|
|
||||||
select m[1] from regexp_matches(r.def, '::\s*([a-z_][a-z0-9_]*)\s*(?:\[\])?', 'gi') m
|
|
||||||
loop
|
|
||||||
if not exists (select 1 from pg_type where typname = lower(v_treffer))
|
|
||||||
and lower(v_treffer) not in (
|
|
||||||
'text', 'int', 'integer', 'boolean', 'bool', 'date', 'uuid', 'jsonb', 'json',
|
|
||||||
'numeric', 'timestamptz', 'varchar', 'bigint', 'smallint', 'real', 'interval', 'time'
|
|
||||||
) then
|
|
||||||
raise exception 'Funktion %() castet auf unbekannten Typ „%".', r.proname, v_treffer;
|
|
||||||
end if;
|
|
||||||
end loop;
|
|
||||||
end loop;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
-- auth.uid() aus den Geschäftsfunktionen entfernen.
|
|
||||||
--
|
|
||||||
-- Die Anwendung verbindet sich als eigene Rolle ohne BYPASSRLS. Diese Rolle
|
|
||||||
-- hat kein Recht auf das Schema `auth` — und jede Funktion, die dort etwas
|
|
||||||
-- aufruft, scheitert mit „permission denied for schema auth".
|
|
||||||
--
|
|
||||||
-- Das trifft **jede schreibende Aktion**: Einstellen, Versetzen, Befördern,
|
|
||||||
-- Austritt, Notizen, Planstellen. Aufgefallen ist es beim Nachstellen einer
|
|
||||||
-- Neueinstellung; zuvor lief alles über eine Rolle, die das Schema sehen
|
|
||||||
-- durfte.
|
|
||||||
--
|
|
||||||
-- app_current_user_id() liefert dasselbe und funktioniert auf jedem
|
|
||||||
-- PostgreSQL — es liest den transaktionslokalen Sitzungskontext, den lib/db
|
|
||||||
-- setzt.
|
|
||||||
|
|
||||||
create or replace function app_uid_ersetzen(p_funktion text)
|
|
||||||
returns void
|
|
||||||
language plpgsql
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
as $$
|
|
||||||
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 = p_funktion
|
|
||||||
limit 1;
|
|
||||||
|
|
||||||
if v_def is null then
|
|
||||||
raise exception 'Funktion %() nicht gefunden.', p_funktion;
|
|
||||||
end if;
|
|
||||||
if position('auth.uid()' in v_def) = 0 then
|
|
||||||
raise notice '%(): bereits umgestellt.', p_funktion;
|
|
||||||
return;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
execute replace(v_def, 'auth.uid()', 'app_current_user_id()');
|
|
||||||
raise notice '%(): umgestellt.', p_funktion;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
select app_uid_ersetzen('add_employee_dependent');
|
|
||||||
select app_uid_ersetzen('add_employee_note');
|
|
||||||
select app_uid_ersetzen('adjust_karenz_return');
|
|
||||||
select app_uid_ersetzen('complete_employee_note');
|
|
||||||
select app_uid_ersetzen('create_position');
|
|
||||||
select app_uid_ersetzen('delete_employee_dependent');
|
|
||||||
select app_uid_ersetzen('delete_position');
|
|
||||||
select app_uid_ersetzen('hire_employee');
|
|
||||||
select app_uid_ersetzen('promote_employee');
|
|
||||||
select app_uid_ersetzen('record_karenz_return');
|
|
||||||
select app_uid_ersetzen('rehire_employee');
|
|
||||||
select app_uid_ersetzen('start_karenz');
|
|
||||||
select app_uid_ersetzen('terminate_employee');
|
|
||||||
select app_uid_ersetzen('transfer_employee');
|
|
||||||
|
|
||||||
drop function app_uid_ersetzen(text);
|
|
||||||
|
|
||||||
-- ═══ Den Rückfall selbst absichern ═══════════════════════════════
|
|
||||||
-- app_current_user_id() behält seinen Übergangszweig auf auth.uid(), fängt
|
|
||||||
-- aber bisher nur „Funktion fehlt". Für eine Rolle ohne Recht auf das Schema
|
|
||||||
-- kommt stattdessen insufficient_privilege — und die Funktion warf, statt
|
|
||||||
-- null zu liefern. Das fiel nicht auf, weil der Zweig nur ohne
|
|
||||||
-- Sitzungskontext erreicht wird; genau dann soll aber „niemand angemeldet"
|
|
||||||
-- herauskommen und kein Fehler.
|
|
||||||
create or replace function app_current_user_id()
|
|
||||||
returns uuid
|
|
||||||
language plpgsql
|
|
||||||
stable
|
|
||||||
security definer
|
|
||||||
set search_path = public, pg_temp
|
|
||||||
as $$
|
|
||||||
declare
|
|
||||||
v_id uuid;
|
|
||||||
begin
|
|
||||||
v_id := nullif(current_setting('app.user_id', true), '')::uuid;
|
|
||||||
if v_id is not null then
|
|
||||||
return v_id;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
begin
|
|
||||||
execute 'select auth.uid()' into v_id;
|
|
||||||
exception
|
|
||||||
when undefined_function or invalid_schema_name or undefined_table or insufficient_privilege then
|
|
||||||
v_id := null;
|
|
||||||
end;
|
|
||||||
return v_id;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
|
||||||
do $$
|
|
||||||
declare r record;
|
|
||||||
begin
|
|
||||||
for r in
|
|
||||||
select p.proname
|
|
||||||
from pg_proc p
|
|
||||||
join pg_namespace n on n.oid = p.pronamespace
|
|
||||||
where n.nspname = 'public' and p.prokind = 'f'
|
|
||||||
and p.proname <> 'app_current_user_id'
|
|
||||||
and pg_get_functiondef(p.oid) like '%auth.uid()%'
|
|
||||||
loop
|
|
||||||
raise exception 'Funktion %() ruft weiterhin auth.uid() auf.', r.proname;
|
|
||||||
end loop;
|
|
||||||
|
|
||||||
-- Ohne Kontext muss die Kennung null sein und darf nicht werfen.
|
|
||||||
perform set_config('app.user_id', '', true);
|
|
||||||
if app_current_user_id() is not null then
|
|
||||||
raise exception 'app_current_user_id() liefert ohne Kontext eine Kennung.';
|
|
||||||
end if;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
-- Klammern in hire_employee.
|
|
||||||
--
|
|
||||||
-- Der Protokolleintrag baute den Namen so:
|
|
||||||
--
|
|
||||||
-- payload->>'first_name' || ' ' || payload->>'last_name'
|
|
||||||
--
|
|
||||||
-- In PostgreSQL bindet `||` **stärker** als `->>`. Gelesen wird also
|
|
||||||
--
|
|
||||||
-- payload ->> ('first_name' || ' ' || payload) ->> 'last_name'
|
|
||||||
--
|
|
||||||
-- und das endet in „operator does not exist: text ->> unknown". Jede
|
|
||||||
-- Neueinstellung scheiterte daran.
|
|
||||||
--
|
|
||||||
-- Warum es niemandem auffiel: davor stand in derselben Anweisung ein Aufruf
|
|
||||||
-- von auth.uid(). Die Rechteprüfung auf das Schema `auth` schlug schon
|
|
||||||
-- während der Analyse fehl, und der Parser kam nie bis zu diesem Ausdruck.
|
|
||||||
-- Ein Fehler hat den anderen verdeckt — beide mussten weg, damit eine
|
|
||||||
-- Einstellung durchläuft.
|
|
||||||
|
|
||||||
do $$
|
|
||||||
declare
|
|
||||||
v_def text;
|
|
||||||
v_alt constant text := 'payload->>''first_name'' || '' '' || payload->>''last_name''';
|
|
||||||
v_neu constant text := '(payload->>''first_name'') || '' '' || (payload->>''last_name'')';
|
|
||||||
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 is null then
|
|
||||||
raise exception 'hire_employee() nicht gefunden.';
|
|
||||||
end if;
|
|
||||||
|
|
||||||
if position(v_alt in v_def) = 0 then
|
|
||||||
raise notice 'hire_employee(): bereits geklammert.';
|
|
||||||
else
|
|
||||||
execute replace(v_def, v_alt, v_neu);
|
|
||||||
raise notice 'hire_employee(): geklammert.';
|
|
||||||
end if;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
|
|
||||||
-- ═══ Gegenprobe ══════════════════════════════════════════════════
|
|
||||||
-- Der Ausdruck selbst, in beiden Lesarten. Ohne Klammern wirft er; mit
|
|
||||||
-- Klammern kommt der Name heraus. Das hält die Regel fest, damit sie beim
|
|
||||||
-- nächsten Mal nicht neu entdeckt werden muss.
|
|
||||||
do $$
|
|
||||||
declare
|
|
||||||
v_payload jsonb := '{"first_name": "Anna", "last_name": "Berger"}'::jsonb;
|
|
||||||
v_name text;
|
|
||||||
begin
|
|
||||||
v_name := (v_payload->>'first_name') || ' ' || (v_payload->>'last_name');
|
|
||||||
if v_name <> 'Anna Berger' then
|
|
||||||
raise exception 'Geklammert ergibt „%" statt „Anna Berger".', v_name;
|
|
||||||
end if;
|
|
||||||
|
|
||||||
begin
|
|
||||||
execute $probe$ select ('{"a":"x"}'::jsonb)->>'a' || ' ' || ('{"a":"x"}'::jsonb)->>'a' $probe$;
|
|
||||||
raise exception 'Ungeklammert wirft nicht mehr — die Vorrangregel hat sich geändert, die Prüfung ist wertlos geworden.';
|
|
||||||
exception
|
|
||||||
when undefined_function then null; -- erwartet: text ->> unknown
|
|
||||||
end;
|
|
||||||
end;
|
|
||||||
$$;
|
|
||||||
565
supabase/seed.ts
565
supabase/seed.ts
@@ -10,9 +10,8 @@ import { createClient } from "@supabase/supabase-js";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
// Explicit .ts extension: this file is run directly by Node (type-stripping,
|
// Explicit .ts extension: this file is run directly by Node (type-stripping,
|
||||||
// ESM), where an extensionless relative import does not resolve.
|
// ESM), where an extensionless relative import does not resolve.
|
||||||
import { svnrCheckDigit, svnrErrorMessage, validateSvnr } from "../lib/svnr.ts";
|
import { svnrCheckDigit } from "../lib/svnr.ts";
|
||||||
import { ABSENCE_TYPES } from "../lib/absence.ts";
|
import { ABSENCE_TYPES } from "../lib/absence.ts";
|
||||||
import { buildOrg, type BuiltUnit, type DivisionDef } from "./build-org.ts";
|
|
||||||
|
|
||||||
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||||
@@ -50,15 +49,8 @@ function addDays(d: Date, days: number): Date {
|
|||||||
r.setDate(r.getDate() + days);
|
r.setDate(r.getDate() + days);
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
// Bewusst *nicht* über toISOString(): alle Daten hier entstehen aus lokalen
|
|
||||||
// Bestandteilen (new Date(jahr, monat, tag), addDays), und toISOString rechnet
|
|
||||||
// nach UTC um. In Österreich verschiebt das jedes Datum um einen Tag nach
|
|
||||||
// hinten — womit das gespeicherte Geburtsdatum nicht mehr zu dem passt, das
|
|
||||||
// makeSvNummer aus denselben lokalen Bestandteilen in die SV-Nummer schreibt.
|
|
||||||
function isoDate(d: Date): string {
|
function isoDate(d: Date): string {
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
return d.toISOString().slice(0, 10);
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${d.getFullYear()}-${m}-${day}`;
|
|
||||||
}
|
}
|
||||||
function randomDateBetween(start: Date, end: Date): Date {
|
function randomDateBetween(start: Date, end: Date): Date {
|
||||||
const t = start.getTime() + Math.random() * (end.getTime() - start.getTime());
|
const t = start.getTime() + Math.random() * (end.getTime() - start.getTime());
|
||||||
@@ -149,8 +141,9 @@ const LOCATION_WEIGHTS: readonly (readonly [(typeof LOCATIONS)[number], number])
|
|||||||
];
|
];
|
||||||
|
|
||||||
// ── Org structure ────────────────────────────────────────────
|
// ── Org structure ────────────────────────────────────────────
|
||||||
// TeamDef/DeptDef/DivisionDef kommen aus build-org.ts — dort steht auch, was
|
type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number };
|
||||||
// daraus gebaut wird.
|
type DeptDef = { name: string; teams: TeamDef[] };
|
||||||
|
type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] };
|
||||||
|
|
||||||
const SCALE = 1.44; // brings the ~556-person base roster up to ~800
|
const SCALE = 1.44; // brings the ~556-person base roster up to ~800
|
||||||
|
|
||||||
@@ -161,7 +154,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
departments: [
|
departments: [
|
||||||
{
|
{
|
||||||
name: "Fertigung",
|
name: "Fertigung",
|
||||||
leadTitle: "Abteilungsleitung Fertigung",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Maschinenbediener:in", "Montagemitarbeiter:in", "Anlagenführer:in"], baseSize: 45 },
|
{ name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Maschinenbediener:in", "Montagemitarbeiter:in", "Anlagenführer:in"], baseSize: 45 },
|
||||||
{ name: "CNC-Fertigung", leadTitle: "Teamleitung CNC-Fertigung", icTitles: ["CNC-Fräser:in", "CNC-Dreher:in", "Zerspanungstechniker:in"], baseSize: 35 },
|
{ name: "CNC-Fertigung", leadTitle: "Teamleitung CNC-Fertigung", icTitles: ["CNC-Fräser:in", "CNC-Dreher:in", "Zerspanungstechniker:in"], baseSize: 35 },
|
||||||
@@ -170,7 +162,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Instandhaltung",
|
name: "Instandhaltung",
|
||||||
leadTitle: "Abteilungsleitung Instandhaltung",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Elektrotechnik", leadTitle: "Teamleitung Elektrotechnik", icTitles: ["Elektrotechniker:in", "Automatisierungstechniker:in"], baseSize: 24 },
|
{ name: "Elektrotechnik", leadTitle: "Teamleitung Elektrotechnik", icTitles: ["Elektrotechniker:in", "Automatisierungstechniker:in"], baseSize: 24 },
|
||||||
{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Industriemechaniker:in", "Schlosser:in"], baseSize: 22 },
|
{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Industriemechaniker:in", "Schlosser:in"], baseSize: 22 },
|
||||||
@@ -184,7 +175,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
departments: [
|
departments: [
|
||||||
{
|
{
|
||||||
name: "Logistik",
|
name: "Logistik",
|
||||||
leadTitle: "Abteilungsleitung Logistik",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Lager", leadTitle: "Teamleitung Lager", icTitles: ["Lagerlogistiker:in", "Staplerfahrer:in", "Kommissionierer:in"], baseSize: 30 },
|
{ name: "Lager", leadTitle: "Teamleitung Lager", icTitles: ["Lagerlogistiker:in", "Staplerfahrer:in", "Kommissionierer:in"], baseSize: 30 },
|
||||||
{ name: "Versand", leadTitle: "Teamleitung Versand", icTitles: ["Versandmitarbeiter:in", "Speditionskaufmann/-frau"], baseSize: 20 },
|
{ name: "Versand", leadTitle: "Teamleitung Versand", icTitles: ["Versandmitarbeiter:in", "Speditionskaufmann/-frau"], baseSize: 20 },
|
||||||
@@ -193,7 +183,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Einkauf",
|
name: "Einkauf",
|
||||||
leadTitle: "Abteilungsleitung Einkauf",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Strategischer Einkauf", leadTitle: "Teamleitung Strategischer Einkauf", icTitles: ["Einkäufer:in", "Category Manager:in"], baseSize: 14 },
|
{ name: "Strategischer Einkauf", leadTitle: "Teamleitung Strategischer Einkauf", icTitles: ["Einkäufer:in", "Category Manager:in"], baseSize: 14 },
|
||||||
{ name: "Operativer Einkauf", leadTitle: "Teamleitung Operativer Einkauf", icTitles: ["Operative:r Einkäufer:in", "Bestelldisponent:in"], baseSize: 14 },
|
{ name: "Operativer Einkauf", leadTitle: "Teamleitung Operativer Einkauf", icTitles: ["Operative:r Einkäufer:in", "Bestelldisponent:in"], baseSize: 14 },
|
||||||
@@ -207,7 +196,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
departments: [
|
departments: [
|
||||||
{
|
{
|
||||||
name: "Vertrieb",
|
name: "Vertrieb",
|
||||||
leadTitle: "Abteilungsleitung Vertrieb",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Key Account Management", leadTitle: "Teamleitung Key Account Management", icTitles: ["Key Account Manager:in", "Sales Manager:in"], baseSize: 16 },
|
{ name: "Key Account Management", leadTitle: "Teamleitung Key Account Management", icTitles: ["Key Account Manager:in", "Sales Manager:in"], baseSize: 16 },
|
||||||
{ name: "Außendienst", leadTitle: "Teamleitung Außendienst", icTitles: ["Außendienstmitarbeiter:in", "Gebietsverkaufsleiter:in"], baseSize: 22 },
|
{ name: "Außendienst", leadTitle: "Teamleitung Außendienst", icTitles: ["Außendienstmitarbeiter:in", "Gebietsverkaufsleiter:in"], baseSize: 22 },
|
||||||
@@ -216,7 +204,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Marketing",
|
name: "Marketing",
|
||||||
leadTitle: "Abteilungsleitung Marketing",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Brand Marketing", leadTitle: "Teamleitung Brand Marketing", icTitles: ["Brand Manager:in", "Produktmanager:in"], baseSize: 12 },
|
{ name: "Brand Marketing", leadTitle: "Teamleitung Brand Marketing", icTitles: ["Brand Manager:in", "Produktmanager:in"], baseSize: 12 },
|
||||||
{ name: "Digital Marketing", leadTitle: "Teamleitung Digital Marketing", icTitles: ["Digital Marketing Manager:in", "Social-Media-Manager:in"], baseSize: 12 },
|
{ name: "Digital Marketing", leadTitle: "Teamleitung Digital Marketing", icTitles: ["Digital Marketing Manager:in", "Social-Media-Manager:in"], baseSize: 12 },
|
||||||
@@ -230,7 +217,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
departments: [
|
departments: [
|
||||||
{
|
{
|
||||||
name: "Produktentwicklung",
|
name: "Produktentwicklung",
|
||||||
leadTitle: "Abteilungsleitung Produktentwicklung",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Rezeptur & Sensorik", leadTitle: "Teamleitung Rezeptur & Sensorik", icTitles: ["Lebensmitteltechniker:in", "Sensoriker:in"], baseSize: 16 },
|
{ name: "Rezeptur & Sensorik", leadTitle: "Teamleitung Rezeptur & Sensorik", icTitles: ["Lebensmitteltechniker:in", "Sensoriker:in"], baseSize: 16 },
|
||||||
{ name: "Verpackungsentwicklung", leadTitle: "Teamleitung Verpackungsentwicklung", icTitles: ["Verpackungstechniker:in", "Packmittelentwickler:in"], baseSize: 12 },
|
{ name: "Verpackungsentwicklung", leadTitle: "Teamleitung Verpackungsentwicklung", icTitles: ["Verpackungstechniker:in", "Packmittelentwickler:in"], baseSize: 12 },
|
||||||
@@ -238,7 +224,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Verfahrenstechnik",
|
name: "Verfahrenstechnik",
|
||||||
leadTitle: "Abteilungsleitung Verfahrenstechnik",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Prozessoptimierung", leadTitle: "Teamleitung Prozessoptimierung", icTitles: ["Verfahrenstechniker:in", "Prozessingenieur:in"], baseSize: 14 },
|
{ name: "Prozessoptimierung", leadTitle: "Teamleitung Prozessoptimierung", icTitles: ["Verfahrenstechniker:in", "Prozessingenieur:in"], baseSize: 14 },
|
||||||
{ name: "Anlagentechnik", leadTitle: "Teamleitung Anlagentechnik", icTitles: ["Anlagentechniker:in", "Projektingenieur:in"], baseSize: 12 },
|
{ name: "Anlagentechnik", leadTitle: "Teamleitung Anlagentechnik", icTitles: ["Anlagentechniker:in", "Projektingenieur:in"], baseSize: 12 },
|
||||||
@@ -252,7 +237,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
departments: [
|
departments: [
|
||||||
{
|
{
|
||||||
name: "Qualitätssicherung",
|
name: "Qualitätssicherung",
|
||||||
leadTitle: "Abteilungsleitung Qualitätssicherung",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Wareneingangsprüfung", leadTitle: "Teamleitung Wareneingangsprüfung", icTitles: ["Qualitätsprüfer:in", "Wareneingangskontrolleur:in"], baseSize: 14 },
|
{ name: "Wareneingangsprüfung", leadTitle: "Teamleitung Wareneingangsprüfung", icTitles: ["Qualitätsprüfer:in", "Wareneingangskontrolleur:in"], baseSize: 14 },
|
||||||
{ name: "Prozessaudit", leadTitle: "Teamleitung Prozessaudit", icTitles: ["Qualitätsauditor:in", "QM-Beauftragte:r"], baseSize: 10 },
|
{ name: "Prozessaudit", leadTitle: "Teamleitung Prozessaudit", icTitles: ["Qualitätsauditor:in", "QM-Beauftragte:r"], baseSize: 10 },
|
||||||
@@ -260,7 +244,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Lebensmittelsicherheit",
|
name: "Lebensmittelsicherheit",
|
||||||
leadTitle: "Abteilungsleitung Lebensmittelsicherheit",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Hygienemanagement", leadTitle: "Teamleitung Hygienemanagement", icTitles: ["Hygienebeauftragte:r", "Lebensmittelsicherheitsbeauftragte:r"], baseSize: 12 },
|
{ name: "Hygienemanagement", leadTitle: "Teamleitung Hygienemanagement", icTitles: ["Hygienebeauftragte:r", "Lebensmittelsicherheitsbeauftragte:r"], baseSize: 12 },
|
||||||
{ name: "Zertifizierung", leadTitle: "Teamleitung Zertifizierung", icTitles: ["Zertifizierungsmanager:in", "QM-Sachbearbeiter:in"], baseSize: 10 },
|
{ name: "Zertifizierung", leadTitle: "Teamleitung Zertifizierung", icTitles: ["Zertifizierungsmanager:in", "QM-Sachbearbeiter:in"], baseSize: 10 },
|
||||||
@@ -274,7 +257,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
departments: [
|
departments: [
|
||||||
{
|
{
|
||||||
name: "Business Applications",
|
name: "Business Applications",
|
||||||
leadTitle: "Abteilungsleitung Business Applications",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "SAP-Team", leadTitle: "Teamleitung SAP-Team", icTitles: ["SAP-Consultant", "SAP-Entwickler:in"], baseSize: 12 },
|
{ name: "SAP-Team", leadTitle: "Teamleitung SAP-Team", icTitles: ["SAP-Consultant", "SAP-Entwickler:in"], baseSize: 12 },
|
||||||
{ name: "Power Platform & Automatisierung", leadTitle: "Teamleitung Power Platform & Automatisierung", icTitles: ["Power Platform Developer:in", "Prozessautomatisierer:in"], baseSize: 10 },
|
{ name: "Power Platform & Automatisierung", leadTitle: "Teamleitung Power Platform & Automatisierung", icTitles: ["Power Platform Developer:in", "Prozessautomatisierer:in"], baseSize: 10 },
|
||||||
@@ -282,7 +264,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Infrastruktur",
|
name: "Infrastruktur",
|
||||||
leadTitle: "Abteilungsleitung Infrastruktur",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Netzwerk & Security", leadTitle: "Teamleitung Netzwerk & Security", icTitles: ["Netzwerktechniker:in", "IT-Security-Spezialist:in"], baseSize: 12 },
|
{ name: "Netzwerk & Security", leadTitle: "Teamleitung Netzwerk & Security", icTitles: ["Netzwerktechniker:in", "IT-Security-Spezialist:in"], baseSize: 12 },
|
||||||
{ name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["IT-Support-Mitarbeiter:in", "Systemadministrator:in"], baseSize: 14 },
|
{ name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["IT-Support-Mitarbeiter:in", "Systemadministrator:in"], baseSize: 14 },
|
||||||
@@ -296,7 +277,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
departments: [
|
departments: [
|
||||||
{
|
{
|
||||||
name: "Finanzen",
|
name: "Finanzen",
|
||||||
leadTitle: "Abteilungsleitung Finanzen",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Buchhaltung", leadTitle: "Teamleitung Buchhaltung", icTitles: ["Buchhalter:in", "Bilanzbuchhalter:in"], baseSize: 16 },
|
{ name: "Buchhaltung", leadTitle: "Teamleitung Buchhaltung", icTitles: ["Buchhalter:in", "Bilanzbuchhalter:in"], baseSize: 16 },
|
||||||
{ name: "Treasury", leadTitle: "Teamleitung Treasury", icTitles: ["Treasury-Manager:in", "Finanzanalyst:in"], baseSize: 10 },
|
{ name: "Treasury", leadTitle: "Teamleitung Treasury", icTitles: ["Treasury-Manager:in", "Finanzanalyst:in"], baseSize: 10 },
|
||||||
@@ -304,7 +284,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Controlling",
|
name: "Controlling",
|
||||||
leadTitle: "Abteilungsleitung Controlling",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Konzerncontrolling", leadTitle: "Teamleitung Konzerncontrolling", icTitles: ["Controller:in", "Financial Analyst:in"], baseSize: 12 },
|
{ name: "Konzerncontrolling", leadTitle: "Teamleitung Konzerncontrolling", icTitles: ["Controller:in", "Financial Analyst:in"], baseSize: 12 },
|
||||||
{ name: "Werkscontrolling", leadTitle: "Teamleitung Werkscontrolling", icTitles: ["Werkscontroller:in", "Kostenrechner:in"], baseSize: 12 },
|
{ name: "Werkscontrolling", leadTitle: "Teamleitung Werkscontrolling", icTitles: ["Werkscontroller:in", "Kostenrechner:in"], baseSize: 12 },
|
||||||
@@ -318,7 +297,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
departments: [
|
departments: [
|
||||||
{
|
{
|
||||||
name: "HR Business Partner",
|
name: "HR Business Partner",
|
||||||
leadTitle: "Abteilungsleitung HR Business Partner",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Recruiting", leadTitle: "Teamleitung Recruiting", icTitles: ["Recruiter:in", "Talent Acquisition Manager:in"], baseSize: 10 },
|
{ name: "Recruiting", leadTitle: "Teamleitung Recruiting", icTitles: ["Recruiter:in", "Talent Acquisition Manager:in"], baseSize: 10 },
|
||||||
{ name: "Personalentwicklung", leadTitle: "Teamleitung Personalentwicklung", icTitles: ["Personalentwickler:in", "Trainer:in"], baseSize: 8 },
|
{ name: "Personalentwicklung", leadTitle: "Teamleitung Personalentwicklung", icTitles: ["Personalentwickler:in", "Trainer:in"], baseSize: 8 },
|
||||||
@@ -326,7 +304,6 @@ const DIVISIONS: DivisionDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Personaladministration",
|
name: "Personaladministration",
|
||||||
leadTitle: "Abteilungsleitung Personaladministration",
|
|
||||||
teams: [
|
teams: [
|
||||||
{ name: "Gehaltsabrechnung", leadTitle: "Teamleitung Gehaltsabrechnung", icTitles: ["Payroll-Spezialist:in", "Personalverrechner:in"], baseSize: 10 },
|
{ name: "Gehaltsabrechnung", leadTitle: "Teamleitung Gehaltsabrechnung", icTitles: ["Payroll-Spezialist:in", "Personalverrechner:in"], baseSize: 10 },
|
||||||
{ name: "HR-Systeme", leadTitle: "Teamleitung HR-Systeme", icTitles: ["HR-IT-Spezialist:in", "HRIS Manager:in"], baseSize: 8 },
|
{ name: "HR-Systeme", leadTitle: "Teamleitung HR-Systeme", icTitles: ["HR-IT-Spezialist:in", "HRIS Manager:in"], baseSize: 8 },
|
||||||
@@ -351,11 +328,13 @@ type EmployeeRow = {
|
|||||||
address_country: string;
|
address_country: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
// Die Einordnung in die Organisation steckt jetzt ausschliesslich in der
|
team_id: string | null;
|
||||||
// Planstelle (position_assignments -> om_positions -> org_units). Keine
|
division_id: string;
|
||||||
// division_id/team_id/manager_id mehr auf der Person.
|
|
||||||
job_title: string;
|
job_title: string;
|
||||||
location_id: string;
|
location_id: string;
|
||||||
|
manager_id: string | null;
|
||||||
|
org_level: number;
|
||||||
|
is_lead: boolean;
|
||||||
employment_type: "Vollzeit" | "Teilzeit";
|
employment_type: "Vollzeit" | "Teilzeit";
|
||||||
weekly_hours: number;
|
weekly_hours: number;
|
||||||
contract_type: "unbefristet" | "befristet";
|
contract_type: "unbefristet" | "befristet";
|
||||||
@@ -424,7 +403,7 @@ function paygradeForIc(): EmployeeRow["paygrade"] {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function newHireBase(jobTitle: string) {
|
function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId: string | null, divisionId: string, managerId: string | null) {
|
||||||
const gender: "m" | "w" = chance(0.48) ? "m" : "w";
|
const gender: "m" | "w" = chance(0.48) ? "m" : "w";
|
||||||
const firstName = pick(gender === "m" ? MALE_FIRST_NAMES : FEMALE_FIRST_NAMES);
|
const firstName = pick(gender === "m" ? MALE_FIRST_NAMES : FEMALE_FIRST_NAMES);
|
||||||
const lastName = pick(LAST_NAMES);
|
const lastName = pick(LAST_NAMES);
|
||||||
@@ -444,35 +423,25 @@ function newHireBase(jobTitle: string) {
|
|||||||
address_country: addressCountryFor(nationality),
|
address_country: addressCountryFor(nationality),
|
||||||
email: makeEmail(firstName, lastName),
|
email: makeEmail(firstName, lastName),
|
||||||
phone: `+43 664 ${randInt(1000000, 9999999)}`,
|
phone: `+43 664 ${randInt(1000000, 9999999)}`,
|
||||||
|
team_id: teamId,
|
||||||
|
division_id: divisionId,
|
||||||
job_title: jobTitle,
|
job_title: jobTitle,
|
||||||
location_id: location.id,
|
location_id: location.id,
|
||||||
|
manager_id: managerId,
|
||||||
|
org_level: orgLevel,
|
||||||
|
is_lead: isLead,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const employees: EmployeeRow[] = [];
|
const employees: EmployeeRow[] = [];
|
||||||
const history: HistoryRow[] = [];
|
const history: HistoryRow[] = [];
|
||||||
|
const icPoolForStatusAssignment: EmployeeRow[] = [];
|
||||||
|
|
||||||
function finalizeEmployee(
|
function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { paygrade: EmployeeRow["paygrade"] }): EmployeeRow {
|
||||||
base: ReturnType<typeof newHireBase>,
|
const age = randInt(22, 60);
|
||||||
opts: { paygrade: EmployeeRow["paygrade"]; entryDate?: Date }
|
|
||||||
): EmployeeRow {
|
|
||||||
// Alter und Eintritt hängen zusammen: sonst entstehen Beschäftigte, die mit
|
|
||||||
// sechs Jahren angefangen haben. Ist der Eintritt vorgegeben (ausgetretene
|
|
||||||
// Vorgänger:innen, geplante Eintritte), richtet sich das Alter danach —
|
|
||||||
// sonst umgekehrt.
|
|
||||||
let age: number;
|
|
||||||
let entryDate: Date;
|
|
||||||
if (opts.entryDate) {
|
|
||||||
entryDate = opts.entryDate;
|
|
||||||
const tenureYears = Math.max(0, Math.floor((TODAY.getTime() - entryDate.getTime()) / (365.25 * 864e5)));
|
|
||||||
const minAge = Math.min(Math.max(22, 20 + tenureYears), 55);
|
|
||||||
age = randInt(minAge, 62);
|
|
||||||
} else {
|
|
||||||
age = randInt(22, 60);
|
|
||||||
const maxTenureYears = Math.min(15, age - 20);
|
|
||||||
entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30));
|
|
||||||
}
|
|
||||||
const birthDate = birthDateForAge(age);
|
const birthDate = birthDateForAge(age);
|
||||||
|
const maxTenureYears = Math.min(15, age - 20);
|
||||||
|
const entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30));
|
||||||
|
|
||||||
const employmentType: "Vollzeit" | "Teilzeit" = chance(0.8) ? "Vollzeit" : "Teilzeit";
|
const employmentType: "Vollzeit" | "Teilzeit" = chance(0.8) ? "Vollzeit" : "Teilzeit";
|
||||||
const weeklyHours = employmentType === "Vollzeit" ? 38.5 : pick([15, 18, 20, 25, 28, 30, 32, 35]);
|
const weeklyHours = employmentType === "Vollzeit" ? 38.5 : pick([15, 18, 20, 25, 28, 30, 32, 35]);
|
||||||
@@ -518,45 +487,80 @@ function finalizeEmployee(
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Organisation im OM-Modell ────────────────────────────────
|
type TeamRef = { id: string; org_number: string; name: string; department_id: string };
|
||||||
// Der Baum kommt aus buildOrg(): reine Funktion, eigene Tests
|
type DeptRef = { id: string; org_number: string; name: string; division_id: string };
|
||||||
// (tests/unit/build-org.test.ts). Der Seed entscheidet hier nur noch, *wer*
|
type DivisionRef = { id: string; org_number: string; name: string };
|
||||||
// welche Planstelle besetzt — die Struktur selbst ist nicht mehr seine Sache.
|
|
||||||
const COMPANY_NAME = "Alpenwerk Industrie GmbH";
|
|
||||||
|
|
||||||
const scaledDivisions: DivisionDef[] = DIVISIONS.map((div) => ({
|
const divisionRows: DivisionRef[] = [];
|
||||||
...div,
|
const departmentRows: DeptRef[] = [];
|
||||||
departments: div.departments.map((dept) => ({
|
const teamRows: TeamRef[] = [];
|
||||||
...dept,
|
|
||||||
// -1, weil die Teamleitung im Altmodell Teil der Teamgrösse war und
|
|
||||||
// buildOrg sie zusätzlich zu icTitles anlegt.
|
|
||||||
teams: dept.teams.map((t) => ({ ...t, baseSize: Math.max(1, Math.round(t.baseSize * SCALE) - 1) })),
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const org = buildOrg(COMPANY_NAME, scaledDivisions, randomUUID);
|
// Geschäftsführung: small division, no departments/teams — CEO and their
|
||||||
const unitById = new Map(org.units.map((u) => [u.id, u]));
|
// assistant sit directly under it (§5).
|
||||||
const jobTitleById = new Map(org.jobs.map((j) => [j.id, j.title]));
|
const gfDivisionId = randomUUID();
|
||||||
|
divisionRows.push({ id: gfDivisionId, org_number: "20900000", name: "Geschäftsführung" });
|
||||||
|
|
||||||
type AssignmentRow = {
|
const ceo = finalizeEmployee(
|
||||||
position_id: string;
|
newHireBase("Geschäftsführer:in", 0, true, null, gfDivisionId, null),
|
||||||
employee_id: string;
|
{ paygrade: "F" }
|
||||||
valid_from: string;
|
);
|
||||||
valid_to: string | null;
|
const gfAssistant = finalizeEmployee(
|
||||||
};
|
newHireBase("Assistenz der Geschäftsführung", 3, false, null, gfDivisionId, ceo.id),
|
||||||
const assignments: AssignmentRow[] = [];
|
{ paygrade: "C" }
|
||||||
|
);
|
||||||
|
employees.push(ceo, gfAssistant);
|
||||||
|
|
||||||
// Wer welche Planstelle besetzt, wird bewusst nicht überall besetzt: Vakanz
|
let divisionCounter = 0;
|
||||||
// ist im OM-Modell keine eigene Tabelle mehr, sondern eine Planstelle ohne
|
let deptCounter = 0;
|
||||||
// laufende Besetzung. Ein paar davon braucht es, damit "offene Stellen" und
|
let teamCounter = 0;
|
||||||
// die Vertretungsregel bei fehlender Leitung überhaupt Daten haben.
|
|
||||||
const VAKANT_IC = 8; // offene Stellen ohne Nachfolge
|
|
||||||
const VAKANT_GEPLANT = 3; // offene Stellen mit Eintritt in der Zukunft
|
|
||||||
const VAKANT_LEITUNG = 3; // unbesetzte Leitungen -> Berichtslinie rollt hoch
|
|
||||||
const AUSGETRETEN = 40; // Vorgänger:innen auf heute besetzten Planstellen
|
|
||||||
const LANGZEITABWESEND = 12;
|
|
||||||
const GEPLANTER_AUSTRITT = 3;
|
|
||||||
|
|
||||||
|
for (const div of DIVISIONS) {
|
||||||
|
divisionCounter += 1;
|
||||||
|
const divisionId = randomUUID();
|
||||||
|
const divisionOrgNumber = `20${String(divisionCounter * 100000).padStart(6, "0")}`;
|
||||||
|
divisionRows.push({ id: divisionId, org_number: divisionOrgNumber, name: div.name });
|
||||||
|
|
||||||
|
const divisionHead = finalizeEmployee(
|
||||||
|
newHireBase(div.headTitle, 1, true, null, divisionId, ceo.id),
|
||||||
|
{ paygrade: "F" }
|
||||||
|
);
|
||||||
|
employees.push(divisionHead);
|
||||||
|
|
||||||
|
for (const dept of div.departments) {
|
||||||
|
deptCounter += 1;
|
||||||
|
const departmentId = randomUUID();
|
||||||
|
const deptOrgNumber = `21${String(deptCounter * 10000).padStart(6, "0")}`;
|
||||||
|
departmentRows.push({ id: departmentId, org_number: deptOrgNumber, name: dept.name, division_id: divisionId });
|
||||||
|
|
||||||
|
for (const team of dept.teams) {
|
||||||
|
teamCounter += 1;
|
||||||
|
const teamId = randomUUID();
|
||||||
|
const teamOrgNumber = `22${String(teamCounter * 1000).padStart(6, "0")}`;
|
||||||
|
teamRows.push({ id: teamId, org_number: teamOrgNumber, name: team.name, department_id: departmentId });
|
||||||
|
|
||||||
|
const size = Math.max(2, Math.round(team.baseSize * SCALE));
|
||||||
|
|
||||||
|
const teamLead = finalizeEmployee(
|
||||||
|
newHireBase(team.leadTitle, 2, true, teamId, divisionId, divisionHead.id),
|
||||||
|
{ paygrade: "E" }
|
||||||
|
);
|
||||||
|
employees.push(teamLead);
|
||||||
|
|
||||||
|
for (let i = 0; i < size - 1; i++) {
|
||||||
|
const jobTitle = pick(team.icTitles);
|
||||||
|
const paygrade = paygradeForIc();
|
||||||
|
const ic = finalizeEmployee(
|
||||||
|
newHireBase(jobTitle, 3, false, teamId, divisionId, teamLead.id),
|
||||||
|
{ paygrade }
|
||||||
|
);
|
||||||
|
employees.push(ic);
|
||||||
|
icPoolForStatusAssignment.push(ic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply the target status distribution (§5) across the IC pool ────────
|
||||||
function shuffle<T>(arr: T[]): T[] {
|
function shuffle<T>(arr: T[]): T[] {
|
||||||
const a = [...arr];
|
const a = [...arr];
|
||||||
for (let i = a.length - 1; i > 0; i--) {
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
@@ -565,74 +569,24 @@ function shuffle<T>(arr: T[]): T[] {
|
|||||||
}
|
}
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
const shuffledIcs = shuffle(icPoolForStatusAssignment);
|
||||||
function paygradeForPosition(p: (typeof org.positions)[number], title: string): EmployeeRow["paygrade"] {
|
|
||||||
if (!p.is_chief) return title.startsWith("Assistenz") ? "C" : paygradeForIc();
|
|
||||||
const type = unitById.get(p.org_unit_id)!.unit_type;
|
|
||||||
return type === "Gesellschaft" || type === "Bereich" ? "F" : "E";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Besetzt eine Planstelle laufend und legt die Person an. */
|
|
||||||
function occupy(p: (typeof org.positions)[number]): EmployeeRow {
|
|
||||||
const title = jobTitleById.get(p.job_id)!;
|
|
||||||
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForPosition(p, title) });
|
|
||||||
employees.push(e);
|
|
||||||
assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null });
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chiefPositions = org.positions.filter((p) => p.is_chief);
|
|
||||||
const icPositions = org.positions.filter((p) => !p.is_chief);
|
|
||||||
|
|
||||||
// Leitungen: alle besetzen bis auf ein paar Teamleitungen, damit die
|
|
||||||
// Hochrollen-Regel im Organigramm sichtbar wird.
|
|
||||||
const vakanteLeitungen = new Set(
|
|
||||||
shuffle(chiefPositions.filter((p) => unitById.get(p.org_unit_id)!.unit_type === "Team"))
|
|
||||||
.slice(0, VAKANT_LEITUNG)
|
|
||||||
.map((p) => p.id)
|
|
||||||
);
|
|
||||||
const leadEmployees: EmployeeRow[] = [];
|
|
||||||
for (const p of chiefPositions) {
|
|
||||||
if (vakanteLeitungen.has(p.id)) continue;
|
|
||||||
leadEmployees.push(occupy(p));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mitarbeiter-Planstellen: der Rest wird besetzt, ein Teil bleibt offen.
|
|
||||||
const shuffledIc = shuffle(icPositions);
|
|
||||||
const offeneStellen = shuffledIc.slice(0, VAKANT_IC);
|
|
||||||
const geplanteStellen = shuffledIc.slice(VAKANT_IC, VAKANT_IC + VAKANT_GEPLANT);
|
|
||||||
const besetzteIc = shuffledIc.slice(VAKANT_IC + VAKANT_GEPLANT);
|
|
||||||
|
|
||||||
const icEmployees = besetzteIc.map((p) => occupy(p));
|
|
||||||
void offeneStellen; // bleiben unbesetzt — genau das macht sie zu offenen Stellen
|
|
||||||
|
|
||||||
// ── Statusverteilung (§5) ────────────────────────────────────
|
|
||||||
const statusPool = shuffle(icEmployees);
|
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
|
|
||||||
// Eintritt in der Zukunft: die Person ist angelegt, die Planstelle heute noch
|
// ~40 Ausgetreten
|
||||||
// vakant, die Besetzung beginnt erst. Genau der Fall, für den die Planstellen
|
for (let i = 0; i < 40 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||||
// zeitabhängig sind.
|
const e = shuffledIcs[cursor];
|
||||||
for (const p of geplanteStellen) {
|
const entryDate = new Date(e.entry_date);
|
||||||
const futureEntry = addDays(TODAY, randInt(10, 90));
|
const exitDate = randomDateBetween(addDays(entryDate, 90), TODAY);
|
||||||
const title = jobTitleById.get(p.job_id)!;
|
e.status = "Ausgetreten";
|
||||||
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate: futureEntry });
|
e.exit_date = isoDate(exitDate);
|
||||||
e.status = "Geplant";
|
e.exit_reason = pick(EXIT_REASONS);
|
||||||
employees.push(e);
|
history.push({ employee_id: e.id, event_date: e.exit_date, event_type: "Austritt", description: `Austritt (${e.exit_reason})` });
|
||||||
assignments.push({ position_id: p.id, employee_id: e.id, valid_from: e.entry_date, valid_to: null });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Langzeitabwesenheit, über die Arten gestreut statt alle als Karenz — sonst
|
// ~12 Langzeitabwesenheiten, über die Arten gestreut statt alle als Karenz —
|
||||||
// ist die Auswertung nach Art nicht zu sehen. Zwei davon treffen bewusst eine
|
// die Auswertung nach Art ist sonst nicht zu sehen.
|
||||||
// Teamleitung, damit die Vertretungsregel auch mit *abwesender* (nicht nur
|
for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||||
// unbesetzter) Leitung Daten hat.
|
const e = shuffledIcs[cursor];
|
||||||
const abwesende: EmployeeRow[] = [
|
|
||||||
...shuffle(leadEmployees.filter((e) => e.job_title.startsWith("Teamleitung"))).slice(0, 2),
|
|
||||||
];
|
|
||||||
while (abwesende.length < LANGZEITABWESEND && cursor < statusPool.length) {
|
|
||||||
abwesende.push(statusPool[cursor++]);
|
|
||||||
}
|
|
||||||
for (const e of abwesende) {
|
|
||||||
const entryDate = new Date(e.entry_date);
|
const entryDate = new Date(e.entry_date);
|
||||||
const karenzStart = randomDateBetween(addDays(entryDate, 180), addDays(TODAY, -10));
|
const karenzStart = randomDateBetween(addDays(entryDate, 180), addDays(TODAY, -10));
|
||||||
const returnDate = addDays(TODAY, randInt(10, 300));
|
const returnDate = addDays(TODAY, randInt(10, 300));
|
||||||
@@ -649,89 +603,65 @@ for (const e of abwesende) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Geplante Austritte: noch aktiv, die Besetzung endet an einem Datum in der
|
// ~3 Geplant (future entry). A person who hasn't started yet can't already
|
||||||
// Zukunft.
|
// have a Beförderung or other history predating that future entry date —
|
||||||
for (let i = 0; i < GEPLANTER_AUSTRITT && cursor < statusPool.length; i++, cursor++) {
|
// found during the consolidation review that reassigning an already-
|
||||||
const e = statusPool[cursor];
|
// finalized IC to Geplant only patched their Eintritt row's date, leaving
|
||||||
|
// any earlier-generated history (e.g. a Beförderung) still on the record
|
||||||
|
// with a date before the (now future) entry_date. Fixed by dropping every
|
||||||
|
// history row for that employee except Eintritt, then moving Eintritt to
|
||||||
|
// the new future date.
|
||||||
|
for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||||
|
const e = shuffledIcs[cursor];
|
||||||
|
const futureEntry = addDays(TODAY, randInt(10, 90));
|
||||||
|
e.status = "Geplant";
|
||||||
|
e.entry_date = isoDate(futureEntry);
|
||||||
|
for (let hi = history.length - 1; hi >= 0; hi--) {
|
||||||
|
if (history[hi].employee_id === e.id && history[hi].event_type !== "Eintritt") history.splice(hi, 1);
|
||||||
|
}
|
||||||
|
const historyEntry = history.find((h) => h.employee_id === e.id && h.event_type === "Eintritt");
|
||||||
|
if (historyEntry) historyEntry.event_date = e.entry_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ~3 planned future exits (still Aktiv until the exit date arrives)
|
||||||
|
for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||||
|
const e = shuffledIcs[cursor];
|
||||||
const futureExit = addDays(TODAY, randInt(10, 90));
|
const futureExit = addDays(TODAY, randInt(10, 90));
|
||||||
e.exit_date = isoDate(futureExit);
|
e.exit_date = isoDate(futureExit);
|
||||||
e.exit_reason = pick(EXIT_REASONS);
|
e.exit_reason = pick(EXIT_REASONS);
|
||||||
const a = assignments.find((x) => x.employee_id === e.id)!;
|
|
||||||
a.valid_to = e.exit_date;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ausgetretene als Vorgänger:innen auf besetzten Planstellen ───────
|
// ── Open positions (§4.6 / §5) ───────────────────────────────
|
||||||
// Im Altmodell hingen Ausgetretene weiter an einem Team und liessen dessen
|
type PositionRow = {
|
||||||
// Planstellen als vakant erscheinen. Im OM-Modell hat eine Planstelle eine
|
title: string;
|
||||||
// Besetzungshistorie: die vorherige Besetzung ist beendet, die heutige läuft.
|
team_id: string;
|
||||||
// Voraussetzung ist, dass der Austritt vor dem Eintritt der heutigen
|
is_lead: boolean;
|
||||||
// Besetzung liegt — sonst wäre die Planstelle zweimal gleichzeitig besetzt.
|
reports_to_employee_id: string | null;
|
||||||
|
status: "open";
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
const positions: PositionRow[] = [];
|
||||||
{
|
{
|
||||||
const holderOf = new Map(icEmployees.map((e) => [e.id, e]));
|
const pool = shuffle([...teamRows]);
|
||||||
const uebernehmbar = shuffle(
|
for (let i = 0; i < 8; i++) {
|
||||||
assignments.filter((a) => {
|
const team = pool[i % pool.length];
|
||||||
const holder = holderOf.get(a.employee_id);
|
const leadOfTeam = employees.find((e) => e.team_id === team.id && e.is_lead);
|
||||||
// Genug Vorlauf, damit vor der heutigen Besetzung noch eine ganze
|
const isLeadPosition = i < 2; // first two are leadership requisitions
|
||||||
// Beschäftigung Platz hat.
|
const reportsTo = isLeadPosition
|
||||||
return holder && new Date(holder.entry_date) > addDays(TODAY, -8 * 365) && holder.status === "Aktiv";
|
? (employees.find((e) => e.division_id === leadOfTeam?.division_id && e.org_level === 1)?.id ?? null)
|
||||||
})
|
: (leadOfTeam?.id ?? null);
|
||||||
).slice(0, AUSGETRETEN);
|
positions.push({
|
||||||
|
title: isLeadPosition ? `Teamleitung ${team.name}` : "Neue Position",
|
||||||
for (const a of uebernehmbar) {
|
team_id: team.id,
|
||||||
const nachfolgerEintritt = new Date(a.valid_from);
|
is_lead: isLeadPosition,
|
||||||
const exitDate = addDays(nachfolgerEintritt, -randInt(1, 60));
|
reports_to_employee_id: reportsTo,
|
||||||
const entryDate = addDays(exitDate, -randInt(400, 3000));
|
status: "open",
|
||||||
const p = org.positions.find((x) => x.id === a.position_id)!;
|
created_at: new Date(addDays(TODAY, -randInt(1, 45))).toISOString(),
|
||||||
const title = jobTitleById.get(p.job_id)!;
|
|
||||||
|
|
||||||
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate });
|
|
||||||
e.status = "Ausgetreten";
|
|
||||||
e.exit_date = isoDate(exitDate);
|
|
||||||
e.exit_reason = pick(EXIT_REASONS);
|
|
||||||
// Ein befristeter Vertrag, der nach dem Austritt endet, wäre Unsinn; und
|
|
||||||
// finalizeEmployee kann eine Beförderung bis heute gestreut haben, die
|
|
||||||
// hier nach dem Austritt läge.
|
|
||||||
e.contract_type = "unbefristet";
|
|
||||||
e.contract_end_date = null;
|
|
||||||
for (let i = history.length - 1; i >= 0; i--) {
|
|
||||||
if (history[i].employee_id === e.id && history[i].event_date > e.exit_date) history.splice(i, 1);
|
|
||||||
}
|
|
||||||
employees.push(e);
|
|
||||||
history.push({
|
|
||||||
employee_id: e.id,
|
|
||||||
event_date: e.exit_date,
|
|
||||||
event_type: "Austritt",
|
|
||||||
description: `Austritt (${e.exit_reason})`,
|
|
||||||
});
|
|
||||||
assignments.push({
|
|
||||||
position_id: p.id,
|
|
||||||
employee_id: e.id,
|
|
||||||
valid_from: e.entry_date,
|
|
||||||
valid_to: e.exit_date,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Insert helpers ───────────────────────────────────────────
|
// ── Insert helpers ───────────────────────────────────────────
|
||||||
/** Eltern vor Kindern, damit parent_id beim Einfügen schon existiert. */
|
|
||||||
function sortParentsFirst(units: BuiltUnit[]): BuiltUnit[] {
|
|
||||||
const byParent = new Map<string | null, BuiltUnit[]>();
|
|
||||||
for (const u of units) {
|
|
||||||
const list = byParent.get(u.parent_id) ?? [];
|
|
||||||
list.push(u);
|
|
||||||
byParent.set(u.parent_id, list);
|
|
||||||
}
|
|
||||||
const out: BuiltUnit[] = [];
|
|
||||||
const queue = [...(byParent.get(null) ?? [])];
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const u = queue.shift()!;
|
|
||||||
out.push(u);
|
|
||||||
queue.push(...(byParent.get(u.id) ?? []));
|
|
||||||
}
|
|
||||||
if (out.length !== units.length) throw new Error("Org-Baum hat abgehängte Einheiten");
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function insertInChunks(table: string, rows: Record<string, unknown>[], chunkSize = 200) {
|
async function insertInChunks(table: string, rows: Record<string, unknown>[], chunkSize = 200) {
|
||||||
for (let i = 0; i < rows.length; i += chunkSize) {
|
for (let i = 0; i < rows.length; i += chunkSize) {
|
||||||
const chunk = rows.slice(i, i + chunkSize);
|
const chunk = rows.slice(i, i + chunkSize);
|
||||||
@@ -741,161 +671,34 @@ async function insertInChunks(table: string, rows: Record<string, unknown>[], ch
|
|||||||
console.log(` inserted ${rows.length} row(s) into ${table}`);
|
console.log(` inserted ${rows.length} row(s) into ${table}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alles ausser den Anmeldekonten. profiles und auth.users bleiben stehen —
|
|
||||||
// sonst sperrt sich der Seed selbst aus der Anwendung aus.
|
|
||||||
//
|
|
||||||
// Reihenfolge: Kinder vor Eltern. org_units verweist auf sich selbst; ein
|
|
||||||
// einzelnes DELETE über alle Zeilen geht trotzdem durch, weil Postgres die
|
|
||||||
// Fremdschlüsselprüfung erst nach dem Statement auswertet.
|
|
||||||
const WIPE_ORDER = [
|
|
||||||
"pending_org_changes",
|
|
||||||
"hire_drafts",
|
|
||||||
"employee_notes",
|
|
||||||
"employee_dependents",
|
|
||||||
"employee_history",
|
|
||||||
"position_assignments",
|
|
||||||
"audit_log",
|
|
||||||
"saved_reports",
|
|
||||||
"employees",
|
|
||||||
"om_positions",
|
|
||||||
"jobs",
|
|
||||||
"org_units",
|
|
||||||
"locations",
|
|
||||||
];
|
|
||||||
|
|
||||||
async function wipe() {
|
|
||||||
for (const table of WIPE_ORDER) {
|
|
||||||
// PostgREST verlangt einen Filter; "id ist nicht null" trifft alles.
|
|
||||||
const { error } = await supabase.from(table).delete().not("id", "is", null);
|
|
||||||
if (error) throw new Error(`Delete from ${table} failed: ${error.message}`);
|
|
||||||
const { count } = await supabase.from(table).select("*", { count: "exact", head: true });
|
|
||||||
if (count) throw new Error(`${table} ist nach dem Löschen nicht leer (${count} Zeilen)`);
|
|
||||||
console.log(` geleert: ${table}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prüft die Zusagen, die der Seed der Datenbank gegenüber macht, bevor er sie
|
|
||||||
* löscht. Die Unique-Indizes fangen das Meiste ab — aber erst nach dem
|
|
||||||
* Löschen, und dann steht die Datenbank leer da.
|
|
||||||
*/
|
|
||||||
function pruefeInvarianten() {
|
|
||||||
const laufend = assignments.filter((a) => a.valid_to === null);
|
|
||||||
|
|
||||||
const jeStelle = new Map<string, number>();
|
|
||||||
for (const a of laufend) jeStelle.set(a.position_id, (jeStelle.get(a.position_id) ?? 0) + 1);
|
|
||||||
for (const [id, n] of jeStelle) if (n > 1) throw new Error(`Planstelle ${id} ist ${n}-fach laufend besetzt`);
|
|
||||||
|
|
||||||
const jePerson = new Map<string, number>();
|
|
||||||
for (const a of laufend) jePerson.set(a.employee_id, (jePerson.get(a.employee_id) ?? 0) + 1);
|
|
||||||
for (const [id, n] of jePerson) if (n > 1) throw new Error(`Person ${id} hat ${n} laufende Planstellen`);
|
|
||||||
|
|
||||||
// Überlappende Besetzungen derselben Planstelle: der Unique-Index deckt nur
|
|
||||||
// die laufende ab, die Historie könnte sich also unbemerkt überschneiden.
|
|
||||||
const nachStelle = new Map<string, AssignmentRow[]>();
|
|
||||||
for (const a of assignments) {
|
|
||||||
const list = nachStelle.get(a.position_id) ?? [];
|
|
||||||
list.push(a);
|
|
||||||
nachStelle.set(a.position_id, list);
|
|
||||||
}
|
|
||||||
for (const [id, list] of nachStelle) {
|
|
||||||
const sortiert = [...list].sort((x, y) => x.valid_from.localeCompare(y.valid_from));
|
|
||||||
for (let i = 1; i < sortiert.length; i++) {
|
|
||||||
const vorher = sortiert[i - 1];
|
|
||||||
if (vorher.valid_to === null || vorher.valid_to > sortiert[i].valid_from) {
|
|
||||||
throw new Error(`Planstelle ${id}: Besetzungen überschneiden sich (${vorher.valid_from}–${vorher.valid_to})`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const a of assignments) {
|
|
||||||
if (a.valid_to !== null && a.valid_to <= a.valid_from) throw new Error(`Besetzung ${a.position_id}: valid_to <= valid_from`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const personen = new Set(employees.map((e) => e.id));
|
|
||||||
for (const a of assignments) if (!personen.has(a.employee_id)) throw new Error("Besetzung ohne Person");
|
|
||||||
for (const h of history) if (!personen.has(h.employee_id)) throw new Error("Historie ohne Person");
|
|
||||||
|
|
||||||
// Jede Person genau eine Planstelle — auch die ausgetretenen, sonst hinge
|
|
||||||
// sie ausserhalb der Organisation.
|
|
||||||
const mitStelle = new Set(assignments.map((a) => a.employee_id));
|
|
||||||
for (const e of employees) if (!mitStelle.has(e.id)) throw new Error(`${e.first_name} ${e.last_name} hat keine Planstelle`);
|
|
||||||
|
|
||||||
// Die SV-Nummer trägt das Geburtsdatum in sich; weichen die beiden
|
|
||||||
// voneinander ab, weist der Trigger die Zeile zurück — mitten im Einfügen,
|
|
||||||
// wenn die Datenbank bereits leergeräumt ist.
|
|
||||||
for (const e of employees) {
|
|
||||||
const fehler = validateSvnr(e.sv_nummer, e.birth_date);
|
|
||||||
if (fehler) throw new Error(`${e.email}: SV-Nummer ${e.sv_nummer} zu Geburtsdatum ${e.birth_date} — ${svnrErrorMessage(fehler)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const e of employees) {
|
|
||||||
if (e.exit_date && e.exit_date <= e.entry_date) throw new Error(`${e.email}: Austritt vor Eintritt`);
|
|
||||||
}
|
|
||||||
for (const h of history) {
|
|
||||||
const e = employees.find((x) => x.id === h.employee_id)!;
|
|
||||||
if (e.exit_date && h.event_date > e.exit_date) throw new Error(`${e.email}: Ereignis ${h.event_type} nach dem Austritt`);
|
|
||||||
// Die Gegenrichtung — und die hat gefehlt.
|
|
||||||
//
|
|
||||||
// trg_history_not_before_entry weist jede Zeile ab, deren event_date vor
|
|
||||||
// dem Eintritt liegt. insertInChunks bricht beim ersten Fehler ab, und
|
|
||||||
// employee_history ist die *letzte* Tabelle im Seed: alles davor war
|
|
||||||
// bereits geschrieben. Ergebnis war eine Datenbank mit 852 Personen und
|
|
||||||
// null Historie — und das sah nicht nach einem Abbruch aus, sondern nach
|
|
||||||
// einer Anwendung, die eben wenig Historie zeigt.
|
|
||||||
if (h.event_date < e.entry_date) {
|
|
||||||
throw new Error(`${e.email}: Ereignis ${h.event_type} am ${h.event_date} liegt vor dem Eintritt am ${e.entry_date}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
pruefeInvarianten();
|
console.log("Seeding locations...");
|
||||||
|
|
||||||
if (process.argv.includes("--dry-run")) {
|
|
||||||
console.log("Trockenlauf — es wird nichts geschrieben.");
|
|
||||||
berichte();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Lösche alle Daten (Anmeldekonten bleiben)...");
|
|
||||||
await wipe();
|
|
||||||
|
|
||||||
console.log("\nSeeding locations...");
|
|
||||||
await insertInChunks("locations", LOCATIONS.map((l) => ({ ...l })));
|
await insertInChunks("locations", LOCATIONS.map((l) => ({ ...l })));
|
||||||
|
|
||||||
console.log(`Seeding ${org.units.length} org_units...`);
|
console.log("Seeding divisions...");
|
||||||
// Eltern vor Kindern: der Fremdschlüssel auf parent_id wird pro Zeile
|
await insertInChunks("divisions", divisionRows);
|
||||||
// geprüft, und insertInChunks zerlegt in mehrere Statements. buildOrg
|
|
||||||
// liefert die Einheiten bereits in dieser Reihenfolge, aber darauf soll
|
|
||||||
// sich der Seed nicht verlassen.
|
|
||||||
await insertInChunks("org_units", sortParentsFirst(org.units));
|
|
||||||
|
|
||||||
console.log(`Seeding ${org.jobs.length} jobs...`);
|
console.log("Seeding departments...");
|
||||||
await insertInChunks("jobs", org.jobs);
|
await insertInChunks("departments", departmentRows);
|
||||||
|
|
||||||
console.log(`Seeding ${org.positions.length} Planstellen...`);
|
console.log("Seeding teams...");
|
||||||
await insertInChunks("om_positions", org.positions);
|
await insertInChunks("teams", teamRows);
|
||||||
|
|
||||||
console.log(`Seeding ${employees.length} employees...`);
|
console.log(`Seeding ${employees.length} employees...`);
|
||||||
await insertInChunks("employees", employees);
|
await insertInChunks("employees", employees);
|
||||||
|
|
||||||
console.log(`Seeding ${assignments.length} Besetzungen...`);
|
|
||||||
await insertInChunks("position_assignments", assignments);
|
|
||||||
|
|
||||||
console.log(`Seeding ${history.length} employee_history rows...`);
|
console.log(`Seeding ${history.length} employee_history rows...`);
|
||||||
await insertInChunks("employee_history", history);
|
await insertInChunks("employee_history", history);
|
||||||
|
|
||||||
// Das HR-Konto wird nicht neu angelegt: die Auth-Konten überstehen den
|
console.log(`Seeding ${positions.length} open positions...`);
|
||||||
// Seed, und ein zweites Konto auf dieselbe Adresse liesse sich gar nicht
|
await insertInChunks("positions", positions);
|
||||||
// anlegen. Fehlt es, wird es einmalig erzeugt — das ist die eine bewusste
|
|
||||||
// Freischaltung, jede weitere profiles-Zeile startet mit is_active = false
|
// The app is HR-only now (see docs/decisions/0001-hr-only-access.md) — no
|
||||||
// und muss von HR freigeschaltet werden (§2.3).
|
// second "manager" role exists to seed a test account for. This is the
|
||||||
const { data: existing } = await supabase.from("profiles").select("id, email").eq("email", ADMIN_EMAIL).maybeSingle();
|
// one deliberate, explicit bootstrap grant of HR access (not an automatic
|
||||||
if (existing) {
|
// one): every other new profile row defaults to is_active = false and
|
||||||
console.log(`\nHR-Konto ${ADMIN_EMAIL} besteht weiter — Passwort unverändert.`);
|
// must be activated by an existing HR user (§2.3).
|
||||||
} else {
|
console.log("Creating initial HR account...");
|
||||||
console.log("\nLege HR-Konto an...");
|
|
||||||
const hrPassword = randomUUID().slice(0, 12) + "!Aa1";
|
const hrPassword = randomUUID().slice(0, 12) + "!Aa1";
|
||||||
const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({
|
const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({
|
||||||
email: ADMIN_EMAIL,
|
email: ADMIN_EMAIL,
|
||||||
@@ -910,40 +713,12 @@ async function main() {
|
|||||||
role: "hr",
|
role: "hr",
|
||||||
is_active: true,
|
is_active: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("\nDone.");
|
||||||
console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`);
|
console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`);
|
||||||
console.log("(Password is shown once here only — store it somewhere safe.)");
|
console.log("(Password is shown once here only — store it somewhere safe.)");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gegenprobe an der Datenbank selbst: die Berichtslinie wird nicht mehr
|
|
||||||
// gepflegt, sondern abgeleitet. Wenn der Seed den Baum falsch verdrahtet
|
|
||||||
// hat, fällt das hier auf und nicht erst im Organigramm.
|
|
||||||
const { data: linien, error: linienErr } = await supabase.rpc("om_reporting_lines", { p_as_of: isoDate(TODAY) });
|
|
||||||
if (linienErr) throw new Error(`om_reporting_lines failed: ${linienErr.message}`);
|
|
||||||
const ohneVorgesetzte = (linien ?? []).filter(
|
|
||||||
(l: { acting_manager_id: string | null }) => l.acting_manager_id === null
|
|
||||||
);
|
|
||||||
console.log(`\nBerichtslinie: ${linien?.length} Zeilen, ${ohneVorgesetzte.length} ohne Vorgesetzte (erwartet: 1, die Geschäftsführung)`);
|
|
||||||
|
|
||||||
console.log("\nFertig.");
|
|
||||||
berichte();
|
|
||||||
}
|
|
||||||
|
|
||||||
function berichte() {
|
|
||||||
const heute = isoDate(TODAY);
|
|
||||||
const laufendHeute = assignments.filter((a) => a.valid_from <= heute && (a.valid_to === null || a.valid_to > heute));
|
|
||||||
const zahl = (t: string) => org.units.filter((u) => u.unit_type === t).length;
|
|
||||||
|
|
||||||
console.log(` Organisation: ${zahl("Gesellschaft")} Gesellschaft, ${zahl("Bereich")} Bereiche, ${zahl("Abteilung")} Abteilungen, ${zahl("Team")} Teams`);
|
|
||||||
console.log(` Jobkatalog: ${org.jobs.length} Tätigkeiten`);
|
|
||||||
console.log(` Planstellen: ${org.positions.length}, davon ${org.positions.length - laufendHeute.length} heute unbesetzt`);
|
|
||||||
console.log(` Personen: ${employees.length}`);
|
|
||||||
for (const s of ["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const) {
|
|
||||||
console.log(` ${s.padEnd(12)} ${employees.filter((e) => e.status === s).length}`);
|
|
||||||
}
|
|
||||||
console.log(` Besetzungen: ${assignments.length} (${assignments.filter((a) => a.valid_to !== null).length} beendet)`);
|
|
||||||
console.log(` Historie: ${history.length} Ereignisse`);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
140
tests/integration/assignment-history.test.ts
Normal file
140
tests/integration/assignment-history.test.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import type { Database } from "@/lib/supabase/types";
|
||||||
|
import {
|
||||||
|
adminClient,
|
||||||
|
createHrUser,
|
||||||
|
deleteTestEmployee,
|
||||||
|
deleteTestUser,
|
||||||
|
hireTestEmployee,
|
||||||
|
isoDateOffset,
|
||||||
|
pickSeededTeam,
|
||||||
|
signInAs,
|
||||||
|
type TestUser,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
// Org-assignment history (supabase/migrations/20260724120000_employee_
|
||||||
|
// assignment_history.sql). The point of capturing this with a trigger rather
|
||||||
|
// than inside each RPC is that it holds for *every* write path — so these
|
||||||
|
// tests drive the real RPCs and assert on the timeline they leave behind.
|
||||||
|
describe("employee_assignments history", () => {
|
||||||
|
let hrUser: TestUser;
|
||||||
|
let hrClient: SupabaseClient<Database>;
|
||||||
|
let teamA: { id: string };
|
||||||
|
let teamB: { id: string };
|
||||||
|
const employeeIds: string[] = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
hrUser = await createHrUser({ active: true });
|
||||||
|
hrClient = await signInAs(hrUser);
|
||||||
|
teamA = await pickSeededTeam();
|
||||||
|
teamB = await pickSeededTeam(teamA.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||||
|
await deleteTestUser(hrUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function freshEmployee(teamId: string): Promise<string> {
|
||||||
|
const id = await hireTestEmployee(hrClient, teamId);
|
||||||
|
employeeIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignmentsFor(employeeId: string) {
|
||||||
|
const { data } = await adminClient
|
||||||
|
.from("employee_assignments")
|
||||||
|
.select("team_id, job_title, valid_from, valid_to")
|
||||||
|
.eq("employee_id", employeeId)
|
||||||
|
.order("valid_from");
|
||||||
|
return data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
it("opens an interval when an employee is hired", async () => {
|
||||||
|
const employeeId = await freshEmployee(teamA.id);
|
||||||
|
const rows = await assignmentsFor(employeeId);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].team_id).toBe(teamA.id);
|
||||||
|
expect(rows[0].valid_to).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the old interval and opens a new one on transfer", async () => {
|
||||||
|
const employeeId = await freshEmployee(teamA.id);
|
||||||
|
const { error } = await hrClient.rpc("transfer_employee", {
|
||||||
|
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||||
|
});
|
||||||
|
expect(error).toBeNull();
|
||||||
|
|
||||||
|
const rows = await assignmentsFor(employeeId);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0].team_id).toBe(teamA.id);
|
||||||
|
expect(rows[0].valid_to).toBe(isoDateOffset(0));
|
||||||
|
expect(rows[1].team_id).toBe(teamB.id);
|
||||||
|
expect(rows[1].valid_to).toBeNull();
|
||||||
|
// Intervals must abut exactly, or an as-of query lands in a gap.
|
||||||
|
expect(rows[1].valid_from).toBe(rows[0].valid_to);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rewrites in place rather than leaving a zero-length interval for a same-day second move", async () => {
|
||||||
|
const employeeId = await freshEmployee(teamA.id);
|
||||||
|
await hrClient.rpc("transfer_employee", {
|
||||||
|
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||||
|
});
|
||||||
|
await hrClient.rpc("transfer_employee", {
|
||||||
|
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamA.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await assignmentsFor(employeeId);
|
||||||
|
expect(rows.every((r) => r.valid_to === null || r.valid_to > r.valid_from)).toBe(true);
|
||||||
|
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
|
||||||
|
expect(rows.at(-1)?.team_id).toBe(teamA.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records a promotion's new title as its own interval", async () => {
|
||||||
|
const employeeId = await freshEmployee(teamA.id);
|
||||||
|
const { error } = await hrClient.rpc("promote_employee", {
|
||||||
|
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_title: "Senior Testtitel" },
|
||||||
|
});
|
||||||
|
expect(error).toBeNull();
|
||||||
|
|
||||||
|
const rows = await assignmentsFor(employeeId);
|
||||||
|
expect(rows.at(-1)?.job_title).toBe("Senior Testtitel");
|
||||||
|
expect(rows.at(-1)?.valid_to).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes no new interval when nothing about the placement changed", async () => {
|
||||||
|
const employeeId = await freshEmployee(teamA.id);
|
||||||
|
const before = await assignmentsFor(employeeId);
|
||||||
|
|
||||||
|
const { error } = await hrClient.rpc("change_employee_data", {
|
||||||
|
payload: {
|
||||||
|
employee_id: employeeId,
|
||||||
|
effective_date: isoDateOffset(0),
|
||||||
|
person: { phone: "+43 1 2345678" },
|
||||||
|
contract: {},
|
||||||
|
role: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(error).toBeNull();
|
||||||
|
|
||||||
|
expect(await assignmentsFor(employeeId)).toHaveLength(before.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps exactly one open interval per employee", async () => {
|
||||||
|
const employeeId = await freshEmployee(teamA.id);
|
||||||
|
await hrClient.rpc("transfer_employee", {
|
||||||
|
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||||
|
});
|
||||||
|
const rows = await assignmentsFor(employeeId);
|
||||||
|
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not readable without an active HR session", async () => {
|
||||||
|
const outsider = await createHrUser({ active: false });
|
||||||
|
const outsiderClient = await signInAs(outsider);
|
||||||
|
const { data } = await outsiderClient.from("employee_assignments").select("id").limit(1);
|
||||||
|
expect(data ?? []).toHaveLength(0);
|
||||||
|
await deleteTestUser(outsider);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,9 +36,7 @@ describe("HR-only access (is_hr_user gate)", () => {
|
|||||||
createdUsers.push(user);
|
createdUsers.push(user);
|
||||||
const client = await signInAs(user);
|
const client = await signInAs(user);
|
||||||
|
|
||||||
const { error } = await client
|
const { error } = await client.from("divisions").insert({ org_number: "20999999", name: `Test-${user.id}` });
|
||||||
.from("org_units")
|
|
||||||
.insert({ org_number: "20999999", name: `Test-${user.id}`, unit_type: "Bereich" });
|
|
||||||
expect(error).not.toBeNull();
|
expect(error).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,11 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
adminClient,
|
adminClient,
|
||||||
createHrUser,
|
createHrUser,
|
||||||
createTestPosition,
|
|
||||||
deleteTestEmployee,
|
deleteTestEmployee,
|
||||||
deleteTestPosition,
|
|
||||||
deleteTestUser,
|
deleteTestUser,
|
||||||
hireTestEmployee,
|
hireTestEmployee,
|
||||||
isoDateOffset,
|
isoDateOffset,
|
||||||
pickSeededUnit,
|
pickSeededTeam,
|
||||||
signInAs,
|
signInAs,
|
||||||
type TestUser,
|
type TestUser,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
@@ -22,32 +20,22 @@ import type { Database } from "@/lib/supabase/types";
|
|||||||
describe("data integrity guards", () => {
|
describe("data integrity guards", () => {
|
||||||
let hrUser: TestUser;
|
let hrUser: TestUser;
|
||||||
let hrClient: SupabaseClient<Database>;
|
let hrClient: SupabaseClient<Database>;
|
||||||
let unitA: { id: string };
|
let teamA: { id: string };
|
||||||
const employeeIds: string[] = [];
|
const employeeIds: string[] = [];
|
||||||
const positionIds: string[] = [];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
hrUser = await createHrUser({ active: true });
|
hrUser = await createHrUser({ active: true });
|
||||||
hrClient = await signInAs(hrUser);
|
hrClient = await signInAs(hrUser);
|
||||||
unitA = await pickSeededUnit();
|
teamA = await pickSeededTeam();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||||
for (const id of positionIds) await deleteTestPosition(id);
|
|
||||||
await deleteTestUser(hrUser);
|
await deleteTestUser(hrUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Jede Einstellung braucht im OM-Modell eine freie Zielplanstelle; eine
|
|
||||||
// geteilte wäre nach der ersten besetzt.
|
|
||||||
async function freshPosition(): Promise<string> {
|
|
||||||
const id = await createTestPosition(hrClient, unitA.id, { valid_from: isoDateOffset(-40) });
|
|
||||||
positionIds.push(id);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function freshEmployeeOnKarenz(): Promise<{ employeeId: string; karenzStartDate: string }> {
|
async function freshEmployeeOnKarenz(): Promise<{ employeeId: string; karenzStartDate: string }> {
|
||||||
const employeeId = await hireTestEmployee(hrClient, await freshPosition());
|
const employeeId = await hireTestEmployee(hrClient, teamA.id);
|
||||||
employeeIds.push(employeeId);
|
employeeIds.push(employeeId);
|
||||||
const karenzStartDate = isoDateOffset(-5);
|
const karenzStartDate = isoDateOffset(-5);
|
||||||
const { error } = await hrClient.rpc("start_karenz", {
|
const { error } = await hrClient.rpc("start_karenz", {
|
||||||
@@ -107,7 +95,7 @@ describe("data integrity guards", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an employee_history row dated before the employee's entry_date", async () => {
|
it("rejects an employee_history row dated before the employee's entry_date", async () => {
|
||||||
const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: isoDateOffset(-10) });
|
const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: isoDateOffset(-10) });
|
||||||
employeeIds.push(employeeId);
|
employeeIds.push(employeeId);
|
||||||
|
|
||||||
const { error } = await adminClient.from("employee_history").insert({
|
const { error } = await adminClient.from("employee_history").insert({
|
||||||
@@ -121,7 +109,7 @@ describe("data integrity guards", () => {
|
|||||||
|
|
||||||
it("accepts an employee_history row dated exactly on the entry_date", async () => {
|
it("accepts an employee_history row dated exactly on the entry_date", async () => {
|
||||||
const entryDate = isoDateOffset(-10);
|
const entryDate = isoDateOffset(-10);
|
||||||
const employeeId = await hireTestEmployee(hrClient, await freshPosition(), { entry_date: entryDate });
|
const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: entryDate });
|
||||||
employeeIds.push(employeeId);
|
employeeIds.push(employeeId);
|
||||||
|
|
||||||
const { error } = await adminClient.from("employee_history").insert({
|
const { error } = await adminClient.from("employee_history").insert({
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
adminClient,
|
adminClient,
|
||||||
chiefOfUnit,
|
|
||||||
createHrUser,
|
createHrUser,
|
||||||
createTestPosition,
|
|
||||||
deleteTestEmployee,
|
deleteTestEmployee,
|
||||||
deleteTestPosition,
|
|
||||||
deleteTestUser,
|
deleteTestUser,
|
||||||
hireTestEmployee,
|
hireTestEmployee,
|
||||||
isoDateOffset,
|
isoDateOffset,
|
||||||
pickSeededUnit,
|
pickSeededTeam,
|
||||||
signInAs,
|
signInAs,
|
||||||
|
teamLeadId,
|
||||||
type TestUser,
|
type TestUser,
|
||||||
} from "./helpers";
|
} from "./helpers";
|
||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
@@ -18,89 +16,60 @@ import type { Database } from "@/lib/supabase/types";
|
|||||||
|
|
||||||
// Deferred/effective-dated changes (supabase/migrations/20260714120200_
|
// Deferred/effective-dated changes (supabase/migrations/20260714120200_
|
||||||
// effective_dating_rpcs.sql): a future "wirksam ab" date must queue a
|
// effective_dating_rpcs.sql): a future "wirksam ab" date must queue a
|
||||||
// pending_org_changes row instead of writing immediately;
|
// pending_org_changes row instead of writing to `employees` immediately;
|
||||||
// apply_due_pending_changes() applies it once due.
|
// apply_due_pending_changes() applies it once due.
|
||||||
//
|
|
||||||
// Im OM-Modell ist eine Versetzung der Wechsel auf eine Zielplanstelle, und
|
|
||||||
// die Berichtslinie wird nicht mehr mitgeschrieben. Geprüft wird deshalb die
|
|
||||||
// laufende Besetzung und was om_reporting_lines daraus ableitet — nicht mehr
|
|
||||||
// employees.team_id/manager_id, die es nicht mehr gibt.
|
|
||||||
describe("effective-dated mutations", () => {
|
describe("effective-dated mutations", () => {
|
||||||
let hrUser: TestUser;
|
let hrUser: TestUser;
|
||||||
let hrClient: SupabaseClient<Database>;
|
let hrClient: SupabaseClient<Database>;
|
||||||
let unitA: { id: string };
|
let teamA: { id: string };
|
||||||
let unitB: { id: string };
|
let teamB: { id: string };
|
||||||
const employeeIds: string[] = [];
|
const employeeIds: string[] = [];
|
||||||
const positionIds: string[] = [];
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
hrUser = await createHrUser({ active: true });
|
hrUser = await createHrUser({ active: true });
|
||||||
hrClient = await signInAs(hrUser);
|
hrClient = await signInAs(hrUser);
|
||||||
unitA = await pickSeededUnit();
|
teamA = await pickSeededTeam();
|
||||||
unitB = await pickSeededUnit(unitA.id);
|
teamB = await pickSeededTeam(teamA.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||||
for (const id of positionIds) await deleteTestPosition(id);
|
|
||||||
await deleteTestUser(hrUser);
|
await deleteTestUser(hrUser);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Eine Wegwerf-Planstelle in `unitId`, alt genug für einen Eintritt vor 30 Tagen. */
|
async function freshEmployee(teamId: string): Promise<string> {
|
||||||
async function freshPosition(unitId: string): Promise<string> {
|
const id = await hireTestEmployee(hrClient, teamId);
|
||||||
const positionId = await createTestPosition(hrClient, unitId, { valid_from: isoDateOffset(-40) });
|
|
||||||
positionIds.push(positionId);
|
|
||||||
return positionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function freshEmployee(unitId: string): Promise<string> {
|
|
||||||
const id = await hireTestEmployee(hrClient, await freshPosition(unitId));
|
|
||||||
employeeIds.push(id);
|
employeeIds.push(id);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function lineOf(employeeId: string) {
|
|
||||||
const { data } = await adminClient
|
|
||||||
.rpc("om_reporting_lines", { p_as_of: isoDateOffset(0) })
|
|
||||||
.eq("employee_id", employeeId)
|
|
||||||
.single();
|
|
||||||
return data as unknown as { org_unit_id: string; formal_manager_id: string | null };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("transfer_employee with today's date writes immediately", async () => {
|
it("transfer_employee with today's date writes immediately", async () => {
|
||||||
const employeeId = await freshEmployee(unitA.id);
|
const employeeId = await freshEmployee(teamA.id);
|
||||||
const target = await freshPosition(unitB.id);
|
const newLead = await teamLeadId(teamB.id);
|
||||||
|
|
||||||
const { error } = await hrClient.rpc("transfer_employee", {
|
const { error } = await hrClient.rpc("transfer_employee", {
|
||||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), target_position_id: target },
|
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||||
});
|
});
|
||||||
expect(error).toBeNull();
|
expect(error).toBeNull();
|
||||||
|
|
||||||
const { data: assignment } = await adminClient
|
const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
|
||||||
.from("position_assignments")
|
expect(employee?.team_id).toBe(teamB.id);
|
||||||
.select("position_id")
|
expect(employee?.manager_id).toBe(newLead);
|
||||||
.eq("employee_id", employeeId)
|
|
||||||
.is("valid_to", null)
|
|
||||||
.single();
|
|
||||||
expect(assignment?.position_id).toBe(target);
|
|
||||||
|
|
||||||
const line = await lineOf(employeeId);
|
|
||||||
expect(line.org_unit_id).toBe(unitB.id);
|
|
||||||
expect(line.formal_manager_id).toBe(await chiefOfUnit(unitB.id));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("transfer_employee with a future date defers the write and applies it once due", async () => {
|
it("transfer_employee with a future date defers the write and applies it once due", async () => {
|
||||||
const employeeId = await freshEmployee(unitA.id);
|
const employeeId = await freshEmployee(teamA.id);
|
||||||
const target = await freshPosition(unitB.id);
|
const newLead = await teamLeadId(teamB.id);
|
||||||
|
|
||||||
const { error } = await hrClient.rpc("transfer_employee", {
|
const { error } = await hrClient.rpc("transfer_employee", {
|
||||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(30), target_position_id: target },
|
payload: { employee_id: employeeId, effective_date: isoDateOffset(30), new_team_id: teamB.id },
|
||||||
});
|
});
|
||||||
expect(error).toBeNull();
|
expect(error).toBeNull();
|
||||||
|
|
||||||
// Not written yet — this is the exact bug the migration fixes: a
|
// Not written yet — this is the exact bug the migration fixes: a
|
||||||
// future-dated transfer must not overwrite the live record today.
|
// future-dated transfer must not overwrite the live record today.
|
||||||
expect((await lineOf(employeeId)).org_unit_id).toBe(unitA.id);
|
const { data: unchanged } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
|
||||||
|
expect(unchanged?.team_id).toBe(teamA.id);
|
||||||
|
|
||||||
const { data: pending } = await adminClient
|
const { data: pending } = await adminClient
|
||||||
.from("pending_org_changes")
|
.from("pending_org_changes")
|
||||||
@@ -109,7 +78,7 @@ describe("effective-dated mutations", () => {
|
|||||||
.eq("change_type", "transfer")
|
.eq("change_type", "transfer")
|
||||||
.single();
|
.single();
|
||||||
expect(pending?.status).toBe("pending");
|
expect(pending?.status).toBe("pending");
|
||||||
expect(pending?.payload.target_position_id).toBe(target);
|
expect(pending?.payload.new_team_id).toBe(teamB.id);
|
||||||
|
|
||||||
// Fast-forward: simulate the effective date having arrived, then run
|
// Fast-forward: simulate the effective date having arrived, then run
|
||||||
// the same function the daily cron route calls.
|
// the same function the daily cron route calls.
|
||||||
@@ -118,14 +87,9 @@ describe("effective-dated mutations", () => {
|
|||||||
expect(applyError).toBeNull();
|
expect(applyError).toBeNull();
|
||||||
expect(appliedCount).toBeGreaterThanOrEqual(1);
|
expect(appliedCount).toBeGreaterThanOrEqual(1);
|
||||||
|
|
||||||
const { data: assignment } = await adminClient
|
const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
|
||||||
.from("position_assignments")
|
expect(employee?.team_id).toBe(teamB.id);
|
||||||
.select("position_id")
|
expect(employee?.manager_id).toBe(newLead);
|
||||||
.eq("employee_id", employeeId)
|
|
||||||
.is("valid_to", null)
|
|
||||||
.single();
|
|
||||||
expect(assignment?.position_id).toBe(target);
|
|
||||||
expect((await lineOf(employeeId)).org_unit_id).toBe(unitB.id);
|
|
||||||
|
|
||||||
const { data: appliedRow } = await adminClient
|
const { data: appliedRow } = await adminClient
|
||||||
.from("pending_org_changes")
|
.from("pending_org_changes")
|
||||||
@@ -137,7 +101,7 @@ describe("effective-dated mutations", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("promote_employee with a future date does not change job_title/paygrade until applied", async () => {
|
it("promote_employee with a future date does not change job_title/paygrade until applied", async () => {
|
||||||
const employeeId = await freshEmployee(unitA.id);
|
const employeeId = await freshEmployee(teamA.id);
|
||||||
|
|
||||||
const { error } = await hrClient.rpc("promote_employee", {
|
const { error } = await hrClient.rpc("promote_employee", {
|
||||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(14), new_title: "Senior Testperson", new_paygrade: "D" },
|
payload: { employee_id: employeeId, effective_date: isoDateOffset(14), new_title: "Senior Testperson", new_paygrade: "D" },
|
||||||
@@ -162,7 +126,7 @@ describe("effective-dated mutations", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("start_karenz with a future date sets karenz_start_date immediately but keeps status Aktiv", async () => {
|
it("start_karenz with a future date sets karenz_start_date immediately but keeps status Aktiv", async () => {
|
||||||
const employeeId = await freshEmployee(unitA.id);
|
const employeeId = await freshEmployee(teamA.id);
|
||||||
const startDate = isoDateOffset(20);
|
const startDate = isoDateOffset(20);
|
||||||
const returnDate = isoDateOffset(200);
|
const returnDate = isoDateOffset(200);
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user