Compare commits
23 Commits
master
...
feat/sap-o
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d8af7cdf0 | |||
| a87c688c0a | |||
| e44f71a60d | |||
| 1271cef879 | |||
| 3926f1bb80 | |||
| 16b37244c8 | |||
| 73656461d1 | |||
| 99e50fbbf9 | |||
| 4d8e3f7154 | |||
| b9efe81bec | |||
| 2ba9b37aa7 | |||
| b3a0af2b8f | |||
| a66263a96e | |||
| 730521ee79 | |||
| acbb03c4f5 | |||
| ee1a38492c | |||
| 2cce101c4b | |||
| 27669e0359 | |||
| 4929252f45 | |||
| c2366e3408 | |||
| cce5c6b0ce | |||
| 35f17858d8 | |||
| 4b9c23472c |
46
.env.example
46
.env.example
@@ -1,12 +1,42 @@
|
||||
# Public: safe to expose to the browser (inlined into the client bundle at
|
||||
# build time). Anon-key access is still fully gated by RLS server-side.
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
# Direkter PostgreSQL-Zugang. Die Anwendung spricht künftig unmittelbar mit
|
||||
# der Datenbank statt über eine API-Schicht — damit läuft sie auf jedem
|
||||
# PostgreSQL ab 15 (Azure Flexible Server, RDS, Cloud SQL, eigenes Blech).
|
||||
#
|
||||
# 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=
|
||||
|
||||
# Server-only: bypasses Row Level Security entirely. Never prefix with
|
||||
# NEXT_PUBLIC_, never import outside lib/supabase/admin.ts (guarded by
|
||||
# `import "server-only"`), never log or return in an API response.
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
# ── Anmeldung (Auth.js + Microsoft Entra ID) ─────────────────────────
|
||||
# Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird.
|
||||
# Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel
|
||||
# meldet alle ab — was im Ernstfall genau das gewünschte Mittel ist.
|
||||
AUTH_SECRET=
|
||||
|
||||
# Aus der Anwendungsregistrierung im Entra-Portal: Anwendungs-ID (Client),
|
||||
# ein Geheimnis daraus, und der Aussteller mit der Verzeichnis-ID (Mandant).
|
||||
#
|
||||
# Der Aussteller darf NICHT auf /common/ stehen bleiben — sonst könnte sich
|
||||
# jedes Microsoft-Konto anmelden, auch ein privates.
|
||||
AUTH_MICROSOFT_ENTRA_ID_ID=
|
||||
AUTH_MICROSOFT_ENTRA_ID_SECRET=
|
||||
AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/<verzeichnis-id>/v2.0
|
||||
|
||||
# Nur nötig, wenn die Anwendung hinter einem Reverse Proxy unter einer
|
||||
# anderen Adresse erreichbar ist, als sie selbst sieht. Ohne diesen Wert baut
|
||||
# Auth.js die Rückruf-Adresse aus den Request-Headern.
|
||||
AUTH_URL=
|
||||
|
||||
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
|
||||
# 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,16 +68,23 @@ jobs:
|
||||
- name: Supabase starten
|
||||
run: supabase start
|
||||
|
||||
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY; the app expects
|
||||
# them under its own names.
|
||||
# `-o env` emits API_URL / ANON_KEY / SERVICE_ROLE_KEY / DB_URL; the app
|
||||
# expects them under its own names. DATABASE_URL ist der direkte
|
||||
# Postgres-Zugang — den braucht die neue Zugriffsschicht (lib/db) und
|
||||
# vor allem der Nachweis zum Sitzungskontext.
|
||||
- name: Testumgebung schreiben
|
||||
run: |
|
||||
supabase status -o env \
|
||||
--override-name api.url=NEXT_PUBLIC_SUPABASE_URL \
|
||||
--override-name auth.anon_key=NEXT_PUBLIC_SUPABASE_ANON_KEY \
|
||||
--override-name auth.service_role_key=SUPABASE_SERVICE_ROLE_KEY \
|
||||
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY)=' \
|
||||
--override-name db.url=DATABASE_URL \
|
||||
| grep -E '^(NEXT_PUBLIC_SUPABASE_URL|NEXT_PUBLIC_SUPABASE_ANON_KEY|SUPABASE_SERVICE_ROLE_KEY|DATABASE_URL)=' \
|
||||
| tr -d '"' > .env.test.local
|
||||
# Lokal läuft Postgres ohne TLS; ohne das versucht `pg` es trotzdem.
|
||||
echo "DATABASE_SSL=false" >> .env.test.local
|
||||
grep -q '^DATABASE_URL=' .env.test.local \
|
||||
|| { echo "DATABASE_URL wurde nicht geschrieben — der Sitzungskontext-Nachweis liefe ins Leere."; exit 1; }
|
||||
|
||||
- name: Seed
|
||||
run: node --env-file=.env.test.local supabase/seed.ts
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -54,3 +54,6 @@ next-env.d.ts
|
||||
# seeded DB — can carry HR data or use SUPABASE_SERVICE_ROLE_KEY; never commit)
|
||||
.scratch_*
|
||||
/.scratch_shots/
|
||||
|
||||
# Datenbank-Sicherungen (enthalten Personendaten) – nie committen.
|
||||
.backups/
|
||||
|
||||
@@ -37,21 +37,22 @@ Werte eintragen:
|
||||
|
||||
| Variable | Woher |
|
||||
|---|---|
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase-Projekt → Settings → API |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase-Projekt → Settings → API |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase-Projekt → Settings → API (geheim!) |
|
||||
| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben; hinter einem Pooler den **Transaktions-Modus** (bei Supabase Port 6543) |
|
||||
| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft |
|
||||
| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | `https://login.microsoftonline.com/<verzeichnis-id>/v2.0` |
|
||||
| `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` |
|
||||
|
||||
Details zur Entra-Registrierung: [`docs/entra-sso.md`](docs/entra-sso.md).
|
||||
|
||||
Wichtig zum Verständnis:
|
||||
|
||||
- `NEXT_PUBLIC_*`-Variablen werden **beim Build** in das Browser-Bundle
|
||||
eingebacken (Next.js-Verhalten, nicht Docker-spezifisch). Ändern sich
|
||||
diese Werte, muss das Image **neu gebaut** werden – ein reiner Container-
|
||||
Neustart reicht nicht.
|
||||
- `SUPABASE_SERVICE_ROLE_KEY` und `CRON_SECRET` sind Server-only-Secrets.
|
||||
Sie werden bewusst **nicht** als Build-Arg übergeben (das würde sie im
|
||||
Image-Layer-History sichtbar machen), sondern erst zur Laufzeit über
|
||||
`env_file` injiziert.
|
||||
- **Nichts davon wird in das Image eingebacken.** Es gibt keine
|
||||
`NEXT_PUBLIC_*`-Variablen mehr; alle Werte liest die Anwendung zur Laufzeit
|
||||
über `env_file`. Eine Änderung braucht deshalb nur einen Neustart, keinen
|
||||
neuen Build — und dasselbe Image läuft in Test und Produktion.
|
||||
- `.env` steht schon in `.gitignore` – nicht committen.
|
||||
|
||||
## 2. Bauen und lokal testen
|
||||
@@ -145,12 +146,22 @@ Single-Instance-Compose-Konfiguration ist das nicht nötig.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Login-Redirect-Loop / `proxy.ts` verhält sich falsch:** meist falsche
|
||||
`NEXT_PUBLIC_SUPABASE_URL`/`ANON_KEY` – Image neu bauen (siehe oben, diese
|
||||
Werte sind eingebacken).
|
||||
- **Anmeldung endet auf `/login?error=…`:** die Umleitungs-URI in der
|
||||
Entra-Registrierung muss exakt
|
||||
`https://<host>/api/auth/callback/microsoft-entra-id` lauten. Steht die
|
||||
Anwendung hinter einem Reverse Proxy unter einer anderen Adresse, als sie
|
||||
selbst sieht, zusätzlich `AUTH_URL` setzen.
|
||||
- **Angemeldet, aber sofort zurück auf `/login?error=no_hr_access`:** die
|
||||
Anmeldung hat funktioniert, es fehlt die Freischaltung. Es braucht eine
|
||||
`profiles`-Zeile mit `role = 'hr'` und `is_active = true` auf derselben
|
||||
Kennung, die in `app_users` steht.
|
||||
- **Cron läuft nicht:** `docker compose logs cron` – prüft, ob
|
||||
`/etc/crontabs/root` korrekt geschrieben wurde und ob `CRON_SECRET` in
|
||||
`.env` gesetzt ist (leer/fehlend führt serverseitig zu `401`).
|
||||
- **Healthcheck rot:** `docker compose logs app` – meist fehlende/falsche
|
||||
Supabase-Env-Variablen zur Laufzeit (`SUPABASE_SERVICE_ROLE_KEY`,
|
||||
Server-Komponenten).
|
||||
- **`max clients reached in session mode`:** der Verbindungsstring zeigt auf
|
||||
den Sitzungs-Modus des Poolers. Auf den Transaktions-Modus wechseln (bei
|
||||
Supabase Port 6543).
|
||||
- **Healthcheck rot:** `docker compose logs app` – meist `DATABASE_URL`
|
||||
fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim
|
||||
ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im
|
||||
Start-Log.
|
||||
|
||||
14
Dockerfile
14
Dockerfile
@@ -12,13 +12,13 @@ WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Public env vars are inlined into the client bundle at build time, so they
|
||||
# must be available here, not just at runtime. Values are passed in via
|
||||
# --build-arg (see DEPLOYMENT.md).
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
# Keine Build-Argumente mehr: es gibt keine NEXT_PUBLIC_*-Werte mehr, die in
|
||||
# das Browser-Bundle eingebacken würden. Datenbank und Anmeldung sprechen
|
||||
# ausschliesslich den Server an, und dessen Zugangsdaten kommen zur Laufzeit.
|
||||
#
|
||||
# Dadurch ist dieses Abbild umgebungsneutral: einmal gebaut, in Test und
|
||||
# Produktion dasselbe. Vorher hätte jede Umgebung ihr eigenes gebraucht — und
|
||||
# eine Baustrecke, die die Zugangsdaten schon zum Bauen kennt.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npm run build
|
||||
|
||||
29
README.md
29
README.md
@@ -54,14 +54,18 @@ Liste. Kurzfassung:
|
||||
|
||||
| Variable | Sichtbarkeit | Zweck |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Browser + Server | Supabase-Projekt-URL |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser + Server | Anon-Key, RLS-gebunden |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | **Nur Server** | Umgeht RLS vollständig — niemals im Browser-Bundle, niemals loggen |
|
||||
| `DATABASE_URL` | Nur Server | PostgreSQL-Verbindung. Die Rolle darf **kein** `BYPASSRLS` haben |
|
||||
| `DATABASE_SSL` | Nur Server | `false` für lokal/CI ohne TLS |
|
||||
| `AUTH_SECRET` | Nur Server | Signiert und verschlüsselt das Sitzungscookie |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Nur Server | Anwendungs-ID der Entra-Registrierung |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_SECRET` | Nur Server | Client-Geheimnis dazu |
|
||||
| `AUTH_MICROSOFT_ENTRA_ID_ISSUER` | Nur Server | Aussteller mit Mandanten-ID — nicht `common` |
|
||||
| `CRON_SECRET` | Nur Server | Schützt `/api/cron/apply-pending-changes` |
|
||||
|
||||
`NEXT_PUBLIC_*`-Werte werden beim Build in das Client-Bundle eingebacken —
|
||||
eine Änderung erfordert einen Rebuild, nicht nur einen Neustart (relevant
|
||||
für Docker-Deployments, siehe unten).
|
||||
**Es gibt keine `NEXT_PUBLIC_*`-Variablen mehr.** Nichts wird in das
|
||||
Browser-Bundle eingebacken, weil der Browser mit nichts ausser der Anwendung
|
||||
selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
|
||||
überall dasselbe — vorher brauchte jede Umgebung ihr eigenes.
|
||||
|
||||
## Scripts
|
||||
|
||||
@@ -85,10 +89,15 @@ für Docker-Deployments, siehe unten).
|
||||
- **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`,
|
||||
geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem —
|
||||
siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell).
|
||||
- **Service-Role-Key ist server-only.** Einzige Verwendung:
|
||||
`lib/supabase/admin.ts`, geschützt durch `import "server-only"` (macht
|
||||
einen versehentlichen Client-Import zu einem Build-Fehler statt einem
|
||||
Laufzeitproblem).
|
||||
- **Es gibt keinen privilegierten Zugang mehr.** Der Dienstschlüssel, der RLS
|
||||
aushebelte, ist ersatzlos entfallen; auch der nächtliche Lauf benutzt
|
||||
dieselbe Rolle ohne `BYPASSRLS`. Was ohne angemeldete Person laufen muss,
|
||||
steht als `SECURITY DEFINER`-Funktion in der Datenbank und prüft dort
|
||||
selbst, was es tut.
|
||||
- **Jede Abfrage läuft in einer Transaktion mit gesetztem Sitzungskontext.**
|
||||
Die Kysely-Instanz wird nicht exportiert — der einzige Weg an die Datenbank
|
||||
ist `withUser()` (`lib/db/index.ts`), und eine ESLint-Regel verbietet den
|
||||
Import von `pg` ausserhalb von `lib/db/`.
|
||||
- **Audit-Log ist transaktional in der Datenbank**, nicht im App-Code: jede
|
||||
mutierende SQL-Funktion schreibt ihren `audit_log`-Eintrag in derselben
|
||||
Transaktion wie die Änderung selbst. Details und Prüfung siehe
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { signIn, signOut } from "@/auth";
|
||||
|
||||
export async function login(formData: FormData) {
|
||||
const email = String(formData.get("email") ?? "");
|
||||
const password = String(formData.get("password") ?? "");
|
||||
// Anmeldung ausschliesslich über Entra ID. Es gibt bewusst keinen
|
||||
// Passwort-Pfad: ein zweiter Anmeldeweg neben dem Firmenkonto hebelt jede
|
||||
// Vorgabe des Mandanten aus — Mehrfaktor, bedingten Zugriff, Sperrung beim
|
||||
// Austritt.
|
||||
//
|
||||
// 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.
|
||||
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
|
||||
if (error) {
|
||||
redirect("/login?error=invalid_credentials");
|
||||
}
|
||||
|
||||
redirect("/");
|
||||
export async function signInWithEntra() {
|
||||
// Kehrt nicht zurück: signIn löst eine Weiterleitung aus, und die wirft in
|
||||
// Next.js.
|
||||
await signIn("microsoft-entra-id", { redirectTo: "/" });
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
const supabase = await createClient();
|
||||
await supabase.auth.signOut();
|
||||
redirect("/login");
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
|
||||
type ActionResult = { success: boolean; error?: string };
|
||||
type MutationFn = keyof Database["public"]["Functions"];
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
|
||||
import type { CollectiveAgreement, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||
|
||||
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.rpc(fn, { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
const result = await runMutation(await currentUserId(), fn, payload);
|
||||
if (!result.success) return result;
|
||||
for (const path of revalidate) revalidatePath(path);
|
||||
return { success: true };
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function hireEmployee(payload: {
|
||||
@@ -24,6 +21,15 @@ export async function hireEmployee(payload: {
|
||||
gender: "m" | "w";
|
||||
birth_date: 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;
|
||||
position_id?: string;
|
||||
team_id?: string;
|
||||
@@ -44,13 +50,19 @@ export async function hireEmployee(payload: {
|
||||
is_laterale_fuehrung?: boolean;
|
||||
is_c_level?: boolean;
|
||||
}): Promise<ActionResult & { employeeId?: string }> {
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase.rpc("hire_employee", { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
|
||||
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
|
||||
try {
|
||||
const employeeId = await withUser(await currentUserId(), (tx) =>
|
||||
callFunction(tx, "hire_employee", payload as Record<string, unknown>)
|
||||
);
|
||||
revalidatePath("/employees");
|
||||
revalidatePath("/");
|
||||
revalidatePath("/positions");
|
||||
return { success: true, employeeId: data as string };
|
||||
return { success: true, employeeId: employeeId as string };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function terminateEmployee(payload: {
|
||||
@@ -65,8 +77,8 @@ export async function terminateEmployee(payload: {
|
||||
export async function transferEmployee(payload: {
|
||||
employee_id: string;
|
||||
effective_date: string;
|
||||
new_team_id: string;
|
||||
new_title?: string;
|
||||
/** Die Zielplanstelle; Bereich, Abteilung und Team ergeben sich aus ihrer Einheit. */
|
||||
target_position_id: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||
}
|
||||
@@ -153,23 +165,3 @@ export async function addEmployeeNote(payload: {
|
||||
export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise<ActionResult> {
|
||||
return callRpc("complete_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
|
||||
}
|
||||
|
||||
export type EmployeeSearchResult = { id: string; first_name: string; last_name: string; job_title: string; team_id: string | null };
|
||||
|
||||
// Shared by "Position besetzen" (staff an open position) and the reorg
|
||||
// workbench's "Mitarbeiter:in(nen)" multi-select — both search active/
|
||||
// on-leave employees by name or title.
|
||||
export async function searchActiveEmployees(query: string): Promise<EmployeeSearchResult[]> {
|
||||
const supabase = await createClient();
|
||||
let q = supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, team_id")
|
||||
.in("status", ["Aktiv", "Karenz"])
|
||||
.limit(20);
|
||||
if (query.trim()) {
|
||||
const term = sanitizeIlikeTerm(query.trim());
|
||||
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||||
}
|
||||
const { data } = await q;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
@@ -1,45 +1,52 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
type ActionResult = { success: boolean; error?: string };
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { ActionResult } from "@/lib/db/rpc";
|
||||
|
||||
export async function saveHireDraft(payload: {
|
||||
id?: string;
|
||||
step: number;
|
||||
data: Record<string, unknown>;
|
||||
}): Promise<ActionResult & { id?: string }> {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, error: "Nicht angemeldet." };
|
||||
const userId = await currentUserId();
|
||||
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
||||
|
||||
try {
|
||||
const id = await withUser(userId, async (tx) => {
|
||||
if (payload.id) {
|
||||
const { error } = await supabase
|
||||
.from("hire_drafts")
|
||||
.update({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
||||
.eq("id", payload.id);
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/");
|
||||
return { success: true, id: payload.id };
|
||||
// Ob die Zeile der aufrufenden Person gehört, entscheidet die
|
||||
// Policy hire_drafts_owner — nicht eine Prüfung hier.
|
||||
await tx
|
||||
.updateTable("hire_drafts")
|
||||
.set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
||||
.where("id", "=", payload.id)
|
||||
.execute();
|
||||
return payload.id;
|
||||
}
|
||||
|
||||
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 };
|
||||
const row = await tx
|
||||
.insertInto("hire_drafts")
|
||||
.values({ created_by: userId, step: payload.step, payload: payload.data })
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
return row.id;
|
||||
});
|
||||
|
||||
revalidatePath("/");
|
||||
return { success: true, id: data.id };
|
||||
return { success: true, id };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteHireDraft(id: string): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.from("hire_drafts").delete().eq("id", id);
|
||||
if (error) return { success: false, error: error.message };
|
||||
try {
|
||||
await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute());
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
type ActionResult = { success: boolean; error?: string };
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { runMutation, type ActionResult } from "@/lib/db/rpc";
|
||||
|
||||
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
||||
|
||||
async function callRpc(
|
||||
fn: "create_position" | "delete_position",
|
||||
fn: "create_position" | "update_position" | "delete_position",
|
||||
payload: Record<string, unknown>,
|
||||
revalidate: string[]
|
||||
): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.rpc(fn, { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
const result = await runMutation(await currentUserId(), fn, payload);
|
||||
if (!result.success) return result;
|
||||
for (const path of revalidate) revalidatePath(path);
|
||||
return { success: true };
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function createPosition(payload: {
|
||||
title: string;
|
||||
superior_employee_id: string;
|
||||
is_lead: boolean;
|
||||
team_id?: string;
|
||||
org_unit_id: string;
|
||||
job_title: string;
|
||||
is_chief: boolean;
|
||||
valid_from: string;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("create_position", payload, POSITION_PATHS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ändert eine Planstelle.
|
||||
*
|
||||
* Weggelassene Felder bleiben, wie sie sind. `valid_to: null` beendet die
|
||||
* Befristung ausdrücklich — deshalb ist es hier `string | null` und nicht
|
||||
* optional: „nicht mitgeschickt" und „auf leer setzen" müssen unterscheidbar
|
||||
* bleiben.
|
||||
*/
|
||||
export async function updatePosition(payload: {
|
||||
position_id: string;
|
||||
org_unit_id?: string;
|
||||
job_title?: string;
|
||||
is_chief?: boolean;
|
||||
valid_from?: string;
|
||||
valid_to?: string | null;
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("update_position", payload, POSITION_PATHS);
|
||||
}
|
||||
|
||||
export async function deletePosition(positionId: string): Promise<ActionResult> {
|
||||
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
|
||||
}
|
||||
|
||||
export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string };
|
||||
|
||||
// For "Position ausschreiben": superior lookup, filtered to team-leads when
|
||||
// the new position is an IC role, or to division-heads/CEO when the new
|
||||
// position is itself a team lead (§2).
|
||||
export async function searchSuperiors(query: string, forLeadPosition: boolean): Promise<SuperiorSearchResult[]> {
|
||||
const supabase = await createClient();
|
||||
let q = supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, division_id")
|
||||
.eq("status", "Aktiv")
|
||||
.limit(20);
|
||||
q = forLeadPosition ? q.lte("org_level", 1) : q.eq("is_lead", true).eq("org_level", 2);
|
||||
if (query.trim()) {
|
||||
const term = sanitizeIlikeTerm(query.trim());
|
||||
q = q.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||||
}
|
||||
const { data } = await q;
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
type ActionResult = { success: boolean; error?: string };
|
||||
|
||||
export type ReorgMovePayload = {
|
||||
kind: "emp" | "team" | "abt" | "dept";
|
||||
label: string;
|
||||
employee_ids: string[];
|
||||
target_team_id: string;
|
||||
};
|
||||
|
||||
export async function applyReorg(payload: {
|
||||
name: string;
|
||||
effective_date: string;
|
||||
moves: ReorgMovePayload[];
|
||||
}): Promise<ActionResult & { scenarioId?: string }> {
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase.rpc("apply_reorg", { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/orgchart");
|
||||
revalidatePath("/employees");
|
||||
revalidatePath("/");
|
||||
return { success: true, scenarioId: data as string };
|
||||
}
|
||||
|
||||
export async function undoReorg(payload: { scenario_id: string }): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.rpc("undo_reorg", { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/orgchart");
|
||||
revalidatePath("/employees");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
}
|
||||
@@ -1,27 +1,31 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
type ActionResult = { success: boolean; error?: string };
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { ActionResult } from "@/lib/db/rpc";
|
||||
|
||||
export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return { success: false, error: "Nicht angemeldet." };
|
||||
const userId = await currentUserId();
|
||||
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
||||
|
||||
const { error } = await supabase.from("saved_reports").insert({ created_by: user.id, name: payload.name, config: payload.config });
|
||||
if (error) return { success: false, error: error.message };
|
||||
try {
|
||||
await withUser(userId, (tx) =>
|
||||
tx.insertInto("saved_reports").values({ created_by: userId, name: payload.name, config: payload.config }).execute()
|
||||
);
|
||||
revalidatePath("/reports");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteReport(id: string): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.from("saved_reports").delete().eq("id", id);
|
||||
if (error) return { success: false, error: error.message };
|
||||
try {
|
||||
await withUser(await currentUserId(), (tx) => tx.deleteFrom("saved_reports").where("id", "=", id).execute());
|
||||
revalidatePath("/reports");
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { AuditDetail } from "@/components/audit/AuditDetail";
|
||||
import { AuditFilters } from "@/components/audit/AuditFilters";
|
||||
import { CARD_CLASS } from "@/components/ui/Card";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -34,33 +35,50 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
||||
|
||||
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams;
|
||||
const supabase = await createClient();
|
||||
|
||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||||
const from = (page - 1) * PAGE_SIZE;
|
||||
const to = from + PAGE_SIZE - 1;
|
||||
|
||||
let query = supabase
|
||||
.from("audit_log")
|
||||
.select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" })
|
||||
.order("occurred_at", { ascending: false })
|
||||
.range(from, to);
|
||||
|
||||
if (params.action) query = query.eq("action", params.action);
|
||||
const { entries, count } = await withUser(await currentUserId(), async (tx) => {
|
||||
const base = () => {
|
||||
let q = tx.selectFrom("audit_log");
|
||||
if (params.action) q = q.where("action", "=", params.action);
|
||||
if (params.q) {
|
||||
const q = sanitizeIlikeTerm(params.q.trim());
|
||||
query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`);
|
||||
// Als Parameter gebunden statt in die Abfrage geschrieben: die
|
||||
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, haben
|
||||
// hier keine Bedeutung mehr.
|
||||
const like = `%${params.q.trim()}%`;
|
||||
q = q.where((eb) =>
|
||||
eb.or([eb("target_label", "ilike", like), eb("details", "ilike", like), eb("actor_name", "ilike", like)])
|
||||
);
|
||||
}
|
||||
return q;
|
||||
};
|
||||
|
||||
const { data: entries, count } = await query;
|
||||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
||||
const [entries, total] = await Promise.all([
|
||||
base()
|
||||
.select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details", "changes"])
|
||||
// Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die
|
||||
// Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten
|
||||
// erscheinen oder auf keiner.
|
||||
.orderBy("occurred_at", "desc")
|
||||
.orderBy("id", "desc")
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE)
|
||||
.execute(),
|
||||
base()
|
||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
return { entries, count: Number(total?.anzahl ?? 0) };
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Suspense>
|
||||
<AuditFilters />
|
||||
</Suspense>
|
||||
<p className="text-sm text-ink-muted">{count ?? 0} Einträge</p>
|
||||
<p className="text-sm text-ink-muted">{count} Einträge</p>
|
||||
|
||||
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||
<table className="w-full min-w-[800px] text-sm">
|
||||
@@ -74,7 +92,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(entries ?? []).map((entry) => {
|
||||
{entries.map((entry) => {
|
||||
return (
|
||||
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
||||
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-ink-body">
|
||||
@@ -98,11 +116,13 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
||||
entry.target_label
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-ink-muted">{entry.details ?? "–"}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<AuditDetail eintrag={entry} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{(entries ?? []).length === 0 && (
|
||||
{entries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
|
||||
Keine Einträge gefunden.
|
||||
|
||||
@@ -1,74 +1,108 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
|
||||
import { loadPlacements, loadReportingLines } from "@/lib/placement";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
type EmployeeWithManager = Database["public"]["Tables"]["employees"]["Row"] & {
|
||||
manager: { id: string; first_name: string; last_name: string; job_title: string } | null;
|
||||
};
|
||||
|
||||
export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
const today = todayIso();
|
||||
|
||||
// Everything here keys off the id already in the URL, and the manager
|
||||
// comes back as an embedded resource on the employee row rather than as a
|
||||
// follow-up query — so the page is one round trip instead of two. Measured
|
||||
// against the hosted database that halved the data time (120ms -> 62ms,
|
||||
// median of five), because a round trip costs more than these queries do.
|
||||
//
|
||||
// The hand-written Database type carries no relationship metadata
|
||||
// (NoRelationships), so the embed is typed at the destructure below.
|
||||
const [
|
||||
{ data: employeeRow },
|
||||
{ data: directReports },
|
||||
{ data: history },
|
||||
{ data: dependents },
|
||||
{ data: notes },
|
||||
{ data: divisions },
|
||||
{ data: departments },
|
||||
{ data: teams },
|
||||
{ data: locations },
|
||||
{ data: openPositions },
|
||||
] = await Promise.all([
|
||||
supabase.from("employees").select("*, manager:manager_id(id, first_name, last_name, job_title)").eq("id", id).single(),
|
||||
supabase.from("employees").select("id, first_name, last_name, job_title, status").eq("manager_id", id).order("last_name"),
|
||||
supabase
|
||||
.from("employee_history")
|
||||
.select("*")
|
||||
.eq("employee_id", id)
|
||||
.order("event_date", { ascending: false })
|
||||
.order("created_at", { ascending: false }),
|
||||
supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"),
|
||||
supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }),
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
supabase.from("departments").select("*"),
|
||||
supabase.from("teams").select("*"),
|
||||
supabase.from("locations").select("*").order("name"),
|
||||
supabase.from("positions").select("id, position_number, title, team_id, is_lead").eq("status", "open"),
|
||||
const data = await withUser(await currentUserId(), async (tx) => {
|
||||
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
|
||||
// kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der
|
||||
// Funktion ein, es wandern also neun Zeilen über die Leitung und nicht
|
||||
// achthundert.
|
||||
const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] =
|
||||
await Promise.all([
|
||||
tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(),
|
||||
loadReportingLines(tx, today, { employeeId: id }),
|
||||
loadReportingLines(tx, today, { actingManagerId: id }),
|
||||
tx
|
||||
.selectFrom("employee_history")
|
||||
.selectAll()
|
||||
.where("employee_id", "=", id)
|
||||
.orderBy("event_date", "desc")
|
||||
.orderBy("created_at", "desc")
|
||||
.execute(),
|
||||
tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(),
|
||||
tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(),
|
||||
loadOrgMaps(tx),
|
||||
loadPlacements(tx, { asOf: today, employeeIds: [id] }),
|
||||
loadOpenPositions(tx),
|
||||
]);
|
||||
|
||||
if (!employeeRow) notFound();
|
||||
if (!employee) return null;
|
||||
const line = ownLines[0] ?? null;
|
||||
|
||||
// Split the embedded manager back off so EmployeeDetail keeps receiving a
|
||||
// plain employees row plus a separate manager, unchanged.
|
||||
const { manager, ...employee } = employeeRow as EmployeeWithManager;
|
||||
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
||||
// formal zuständige Leitung und die direkten Berichte.
|
||||
const relatedIds = Array.from(
|
||||
new Set(
|
||||
[line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter(
|
||||
(x): x is string => Boolean(x)
|
||||
)
|
||||
)
|
||||
);
|
||||
const relatedRows = relatedIds.length
|
||||
? await tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "job_title", "status"])
|
||||
.where("id", "in", relatedIds)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
return {
|
||||
employee,
|
||||
line,
|
||||
reports,
|
||||
history,
|
||||
dependents,
|
||||
notes,
|
||||
orgMaps,
|
||||
placement: placements.get(id) ?? null,
|
||||
openPositions,
|
||||
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
||||
};
|
||||
});
|
||||
|
||||
if (!data) notFound();
|
||||
const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data;
|
||||
|
||||
return (
|
||||
<EmployeeDetail
|
||||
employee={employee}
|
||||
manager={manager ?? null}
|
||||
directReports={directReports ?? []}
|
||||
placement={
|
||||
placement && {
|
||||
positionNumber: placement.positionNumber,
|
||||
jobTitle: placement.jobTitle,
|
||||
isChief: placement.isChief,
|
||||
current: placement.current,
|
||||
}
|
||||
}
|
||||
breadcrumb={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
|
||||
manager={(line?.acting_manager_id ? byId.get(line.acting_manager_id) : null) ?? null}
|
||||
// Nur wenn eine Vertretung im Spiel ist — sonst stünde dieselbe Person
|
||||
// zweimal da.
|
||||
formalManager={
|
||||
line?.formal_manager_id && line.formal_manager_id !== line.acting_manager_id
|
||||
? (byId.get(line.formal_manager_id) ?? null)
|
||||
: null
|
||||
}
|
||||
directReports={reports.flatMap((r) => {
|
||||
const e = byId.get(r.employee_id);
|
||||
return e ? [e] : [];
|
||||
})}
|
||||
history={history ?? []}
|
||||
dependents={dependents ?? []}
|
||||
notes={notes ?? []}
|
||||
divisions={divisions ?? []}
|
||||
departments={departments ?? []}
|
||||
teams={teams ?? []}
|
||||
locations={locations ?? []}
|
||||
openPositions={openPositions ?? []}
|
||||
locations={orgMaps.locationList}
|
||||
openPositions={openPositions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import { Avatar } from "@/components/ui/Avatar";
|
||||
import { CARD_CLASS } from "@/components/ui/Card";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { StatusChip } from "@/components/ui/StatusChip";
|
||||
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
|
||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
@@ -32,53 +33,115 @@ function pageHref(params: SearchParams, page: number): string {
|
||||
|
||||
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
||||
const params = await searchParams;
|
||||
const supabase = await createClient();
|
||||
|
||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||||
const from = (page - 1) * PAGE_SIZE;
|
||||
const to = from + PAGE_SIZE - 1;
|
||||
const today = todayIso();
|
||||
|
||||
let query = supabase
|
||||
.from("employees")
|
||||
.select(
|
||||
"id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status, absence_type",
|
||||
{ count: "exact" }
|
||||
)
|
||||
.order("last_name", { ascending: true })
|
||||
.range(from, to);
|
||||
|
||||
if (params.q) {
|
||||
const q = params.q.trim();
|
||||
if (/^\d+$/.test(q)) {
|
||||
query = query.eq("personnel_number", Number(q));
|
||||
} else {
|
||||
const term = sanitizeIlikeTerm(q);
|
||||
query = query.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||||
}
|
||||
}
|
||||
if (params.division) query = query.eq("division_id", params.division);
|
||||
// Comma-separated, so a dashboard tile can link here with the same
|
||||
// status set it counted rather than a narrower one.
|
||||
const statuses = (params.status ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
||||
|
||||
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
|
||||
// lib/employee-status-filter.ts for why the two can disagree.
|
||||
query = applyDerivedStatusFilter(query, statuses, todayIso());
|
||||
if (params.location) query = query.eq("location_id", params.location);
|
||||
if (statuses.length > 0) {
|
||||
q = q.where((eb) => derivedStatusFilter(eb, statuses, today) ?? eb.val(true));
|
||||
}
|
||||
|
||||
// The org lookup tables are needed only to label the rows, so they load
|
||||
// alongside the page of employees instead of before it — one round trip
|
||||
// saved on a page that is otherwise two fast queries.
|
||||
const [orgMaps, { data: employeesData, count }] = await Promise.all([loadOrgMaps(supabase), query]);
|
||||
const employees = employeesData ?? [];
|
||||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
||||
if (params.location) q = q.where("location_id", "=", params.location);
|
||||
return q;
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
base()
|
||||
.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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Suspense>
|
||||
<EmployeeFilters divisions={orgMaps.divisionList} locations={orgMaps.locationList} />
|
||||
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
|
||||
</Suspense>
|
||||
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
|
||||
|
||||
@@ -97,7 +160,9 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
</thead>
|
||||
<tbody>
|
||||
{employees.map((e) => {
|
||||
const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
|
||||
const placement = placements.get(e.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;
|
||||
return (
|
||||
// border-subtle between rows: the full-strength border made
|
||||
@@ -113,7 +178,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
<div className="truncate font-semibold text-ink">
|
||||
{e.first_name} {e.last_name}
|
||||
</div>
|
||||
<div className="truncate text-xs text-ink-muted">{e.job_title}</div>
|
||||
<div className="truncate text-xs text-ink-muted">{placement?.jobTitle ?? e.job_title}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
@@ -122,7 +187,12 @@ 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 text-ink-body">
|
||||
<div>{division?.name ?? "–"}</div>
|
||||
<div className="text-xs text-ink-muted">{team?.name ?? "–"}</div>
|
||||
{/* Die eigene Einheit, egal auf welcher Ebene sie hängt —
|
||||
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 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>
|
||||
|
||||
16
app/(app)/import/page.tsx
Normal file
16
app/(app)/import/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { ImportWorkbench } from "@/components/import/ImportWorkbench";
|
||||
|
||||
export const metadata = { title: "Import" };
|
||||
|
||||
export default function ImportPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="max-w-prose text-sm text-ink-muted">
|
||||
Übernahme aus einer Datei — Organisation, Planstellen, Personen, Historie und Angehörige. Angelegt wird nur;
|
||||
bestehende Datensätze werden nie überschrieben. Geprüft wird vor dem Schreiben, und geschrieben wird alles
|
||||
zusammen oder gar nichts.
|
||||
</p>
|
||||
<ImportWorkbench />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,36 +2,55 @@ import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
||||
import { AppShell } from "@/components/shell/AppShell";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { loadOpenNotes } from "@/lib/notes";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect("/login");
|
||||
const userId = await currentUserId();
|
||||
if (!userId) redirect("/login");
|
||||
|
||||
// Defense in depth: proxy.ts already redirects any non-active-HR session
|
||||
// away before this layout ever renders. Re-checking here means a gap in
|
||||
// the proxy matcher (or a future route added outside it) still fails
|
||||
// closed instead of silently granting access — see docs/security.md.
|
||||
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle();
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login");
|
||||
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
|
||||
// und damit nebenbei auf einem einheitlichen Lesestand.
|
||||
const data = await withUser(userId, async (tx) => {
|
||||
// Hier — und nicht im Proxy — fällt die Entscheidung über den Zugang.
|
||||
// Der Proxy prüft nur, ob überhaupt jemand angemeldet ist; er hat keine
|
||||
// Datenbankverbindung. Diese Abfrage läuft bei jedem Aufbau frisch, eine
|
||||
// entzogene Freischaltung wirkt also sofort statt erst mit dem nächsten
|
||||
// Sitzungstoken. Die eigentliche Grenze bleibt darunter RLS.
|
||||
const profile = await tx
|
||||
.selectFrom("profiles")
|
||||
.select(["full_name", "email", "role", "is_active"])
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
|
||||
|
||||
const userLabel = profile.full_name || profile.email || user.email || "";
|
||||
|
||||
const [openPositions, locationsRes, draftsRes, openNotes] = await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("locations").select("id, name, country").order("name"),
|
||||
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
||||
loadOpenNotes(supabase),
|
||||
const [openPositions, locations, drafts, openNotes] = await Promise.all([
|
||||
loadOpenPositions(tx),
|
||||
tx.selectFrom("locations").select(["id", "name", "country"]).orderBy("name").execute(),
|
||||
tx
|
||||
.selectFrom("hire_drafts")
|
||||
.select(["id", "step", "payload", "updated_at"])
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("updated_at", "desc")
|
||||
.execute(),
|
||||
loadOpenNotes(tx),
|
||||
]);
|
||||
|
||||
return { profile, openPositions, locations, drafts, openNotes };
|
||||
});
|
||||
|
||||
// `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 (
|
||||
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
|
||||
<AppShell userLabel={userLabel} openNotes={openNotes}>
|
||||
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
|
||||
<AppShell userLabel={userLabel} openNotes={data.openNotes}>
|
||||
{children}
|
||||
</AppShell>
|
||||
</HireWizardProvider>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Suspense } from "react";
|
||||
import { OrgChartClient } from "@/components/orgchart/OrgChartClient";
|
||||
import type { OrgUnitNode } from "@/components/orgchart/types";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { parseIsoDateParam } from "@/lib/reports";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
type SearchParams = { asOf?: string; focus?: string };
|
||||
|
||||
@@ -18,32 +19,20 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro
|
||||
// so a junk value can't reach the client as an arbitrary string.
|
||||
const focusId = params.focus && UUID.test(params.focus) ? params.focus : null;
|
||||
|
||||
const supabase = await createClient();
|
||||
|
||||
const [org, { data: divisions }, { data: departments }, { data: teams }, openPositions, { data: reorgScenarios }] =
|
||||
await Promise.all([
|
||||
loadOrgAsOf(supabase, asOf),
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
supabase.from("departments").select("*"),
|
||||
supabase.from("teams").select("*"),
|
||||
loadOpenPositions(supabase),
|
||||
supabase
|
||||
.from("reorg_scenarios")
|
||||
.select("id, name, effective_date, applied, applied_at")
|
||||
.eq("applied", true)
|
||||
.order("applied_at", { ascending: false })
|
||||
.limit(5),
|
||||
const { org, units } = await withUser(await currentUserId(), async (tx) => {
|
||||
const [org, units] = await Promise.all([
|
||||
loadOrgAsOf(tx, asOf),
|
||||
tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(),
|
||||
]);
|
||||
return { org, units };
|
||||
});
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<OrgChartClient
|
||||
employees={org.employees}
|
||||
divisions={divisions ?? []}
|
||||
departments={departments ?? []}
|
||||
teams={teams ?? []}
|
||||
openPositions={openPositions}
|
||||
reorgScenarios={reorgScenarios ?? []}
|
||||
units={units as OrgUnitNode[]}
|
||||
vacancies={org.vacancies}
|
||||
asOf={asOf}
|
||||
today={today}
|
||||
projectedCount={org.projectedCount}
|
||||
|
||||
@@ -4,9 +4,13 @@ import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
||||
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
|
||||
import { divisionOf, loadOrgMaps } from "@/lib/org";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { deriveStatusAsOf } from "@/lib/reports";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { fetchAllRows } from "@/lib/supabase/query";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { HistoryEventType } from "@/lib/supabase/types";
|
||||
|
||||
// Each KPI carries a colour already; the accent bar repeats it in a second
|
||||
// channel so the tiles are scannable as a row rather than six identical
|
||||
@@ -36,18 +40,6 @@ const DOT_STYLES: Record<string, string> = {
|
||||
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const;
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
const { data: drafts } = user
|
||||
? await supabase
|
||||
.from("hire_drafts")
|
||||
.select("id, step, payload, updated_at")
|
||||
.eq("created_by", user.id)
|
||||
.order("updated_at", { ascending: false })
|
||||
: { data: [] };
|
||||
|
||||
// Built as strings, not by round-tripping a local Date through
|
||||
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
|
||||
// the previous year in UTC, which shifted the whole YTD window a day early
|
||||
@@ -58,6 +50,8 @@ export default async function DashboardPage() {
|
||||
const yearEnd = `${year}-12-31`;
|
||||
const in60Iso = addDaysIso(today, 60);
|
||||
|
||||
const userId = await currentUserId();
|
||||
|
||||
// Headcount, FTE, Karenz and the division bars all come from one full read
|
||||
// and the *derived* status, not from the `employees.status` column.
|
||||
//
|
||||
@@ -66,69 +60,117 @@ export default async function DashboardPage() {
|
||||
// planned hire whose start date has passed, or a Karenz that ended without
|
||||
// anyone recording the return, made the dashboard and the Berichte page
|
||||
// disagree about the same headcount. Same derivation, same numbers.
|
||||
// It also replaces four separate count queries with one.
|
||||
const [
|
||||
const {
|
||||
drafts,
|
||||
staffRows,
|
||||
hiresYtdRes,
|
||||
exitsYtdRes,
|
||||
openPositionsRes,
|
||||
divisionsRes,
|
||||
upcomingHiresRes,
|
||||
upcomingExitsRes,
|
||||
upcomingReturnsRes,
|
||||
historyRes,
|
||||
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 [
|
||||
drafts,
|
||||
staffRows,
|
||||
hiresYtd,
|
||||
exitsYtd,
|
||||
openPositions,
|
||||
orgMaps,
|
||||
placements,
|
||||
upcomingHires,
|
||||
upcomingExits,
|
||||
upcomingReturns,
|
||||
history,
|
||||
] = await Promise.all([
|
||||
fetchAllRows(() =>
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("weekly_hours, division_id, entry_date, exit_date, karenz_start_date, karenz_return_date")
|
||||
.order("id")
|
||||
),
|
||||
userId
|
||||
? tx
|
||||
.selectFrom("hire_drafts")
|
||||
.select(["id", "step", "payload", "updated_at"])
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("updated_at", "desc")
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
|
||||
.orderBy("id")
|
||||
.execute(),
|
||||
|
||||
// Entries/exits count history events, which is what the linked report
|
||||
// counts too. `entry_date` would also sweep up rehires, whose event is
|
||||
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
||||
// different numbers for the same year.
|
||||
supabase
|
||||
.from("employee_history")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.in("event_type", ["Eintritt", "Wiedereintritt"])
|
||||
.gte("event_date", yearStart)
|
||||
.lte("event_date", yearEnd),
|
||||
supabase
|
||||
.from("employee_history")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("event_type", "Austritt")
|
||||
.gte("event_date", yearStart)
|
||||
.lte("event_date", yearEnd),
|
||||
supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
|
||||
supabase.from("divisions").select("id, name"),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, entry_date")
|
||||
.eq("status", "Geplant")
|
||||
.gte("entry_date", today)
|
||||
.lte("entry_date", in60Iso),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, exit_date")
|
||||
.not("exit_date", "is", null)
|
||||
.gte("exit_date", today)
|
||||
.lte("exit_date", in60Iso),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, karenz_return_date")
|
||||
.eq("status", "Karenz")
|
||||
.not("karenz_return_date", "is", null)
|
||||
.gte("karenz_return_date", today)
|
||||
.lte("karenz_return_date", in60Iso),
|
||||
supabase
|
||||
.from("employee_history")
|
||||
.select("id, employee_id, event_date, event_type, description")
|
||||
.order("event_date", { ascending: false })
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(10),
|
||||
countIn(["Eintritt", "Wiedereintritt"]),
|
||||
countIn(["Austritt"]),
|
||||
|
||||
loadOpenPositions(tx),
|
||||
loadOrgMaps(tx),
|
||||
loadPlacements(tx, { asOf: today }),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "entry_date"])
|
||||
.where("status", "=", "Geplant")
|
||||
.where("entry_date", ">=", today)
|
||||
.where("entry_date", "<=", in60Iso)
|
||||
.execute(),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "exit_date"])
|
||||
.where("exit_date", "is not", null)
|
||||
.where("exit_date", ">=", today)
|
||||
.where("exit_date", "<=", in60Iso)
|
||||
.execute(),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "karenz_return_date"])
|
||||
.where("status", "=", "Karenz")
|
||||
.where("karenz_return_date", "is not", null)
|
||||
.where("karenz_return_date", ">=", today)
|
||||
.where("karenz_return_date", "<=", in60Iso)
|
||||
.execute(),
|
||||
|
||||
tx
|
||||
.selectFrom("employee_history as h")
|
||||
.leftJoin("employees as e", "e.id", "h.employee_id")
|
||||
.select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"])
|
||||
.orderBy("h.event_date", "desc")
|
||||
.orderBy("h.created_at", "desc")
|
||||
.limit(10)
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
return {
|
||||
drafts,
|
||||
staffRows,
|
||||
hiresYtd: Number(hiresYtd?.anzahl ?? 0),
|
||||
exitsYtd: Number(exitsYtd?.anzahl ?? 0),
|
||||
openPositions,
|
||||
orgMaps,
|
||||
placements,
|
||||
upcomingHires,
|
||||
upcomingExits,
|
||||
upcomingReturns,
|
||||
history,
|
||||
};
|
||||
});
|
||||
|
||||
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
||||
// active, and is counted by its own tile instead. FTE follows the same
|
||||
// set: Karenz contributes no capacity, so including it would overstate
|
||||
@@ -143,31 +185,37 @@ export default async function DashboardPage() {
|
||||
const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length;
|
||||
const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
|
||||
|
||||
// Der Bereich einer Person steht nicht mehr auf ihr; er ergibt sich aus der
|
||||
// Einheit ihrer Planstelle und deren Vorfahren. Die Bereichsleitung selbst
|
||||
// sitzt *am* Bereich, ihre Leute darunter — beide landen über die
|
||||
// Vorfahrenkette im selben Balken.
|
||||
const headcountByDivision = new Map<string, number>();
|
||||
for (const row of activeStaff) {
|
||||
if (!row.division_id) continue;
|
||||
headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
|
||||
const division = divisionOf(orgMaps, placements.get(row.id)?.orgUnitId);
|
||||
if (!division) continue;
|
||||
headcountByDivision.set(division.id, (headcountByDivision.get(division.id) ?? 0) + 1);
|
||||
}
|
||||
const divisionBars = (divisionsRes.data ?? [])
|
||||
const divisionBars = orgMaps.unitList
|
||||
.filter((u) => u.unit_type === "Bereich")
|
||||
.map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
|
||||
|
||||
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
|
||||
const upcoming: UpcomingItem[] = [
|
||||
...(upcomingHiresRes.data ?? []).map((e) => ({
|
||||
...(upcomingHires).map((e) => ({
|
||||
id: e.id,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
date: e.entry_date,
|
||||
kind: "hire" as const,
|
||||
})),
|
||||
...(upcomingExitsRes.data ?? []).map((e) => ({
|
||||
...(upcomingExits).map((e) => ({
|
||||
id: e.id,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
date: e.exit_date!,
|
||||
kind: "exit" as const,
|
||||
})),
|
||||
...(upcomingReturnsRes.data ?? []).map((e) => ({
|
||||
...(upcomingReturns).map((e) => ({
|
||||
id: e.id,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
date: e.karenz_return_date!,
|
||||
@@ -177,12 +225,6 @@ export default async function DashboardPage() {
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.slice(0, 8);
|
||||
|
||||
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
|
||||
const historyEmployeesRes = historyEmployeeIds.length
|
||||
? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds)
|
||||
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
||||
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
||||
|
||||
// Each tile links to the view that shows what it counts, with the filters
|
||||
// pre-applied.
|
||||
//
|
||||
@@ -203,18 +245,18 @@ export default async function DashboardPage() {
|
||||
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
||||
{
|
||||
label: "Eintritte (Jahr)",
|
||||
value: hiresYtdRes.count ?? 0,
|
||||
value: hiresYtd,
|
||||
tone: "success",
|
||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
||||
},
|
||||
{
|
||||
label: "Austritte (Jahr)",
|
||||
value: exitsYtdRes.count ?? 0,
|
||||
value: exitsYtd,
|
||||
tone: "danger",
|
||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||
},
|
||||
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
||||
{ label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand", href: "/positions" },
|
||||
{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -289,14 +331,14 @@ export default async function DashboardPage() {
|
||||
<Card>
|
||||
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||
{(historyRes.data ?? []).map((h) => (
|
||||
{(history).map((h) => (
|
||||
<li key={h.id} className="flex gap-2.5 py-2.5">
|
||||
{/* Dot aligned to the first line of text, not centred on the
|
||||
whole row, so it stays put as descriptions wrap. */}
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-semibold text-ink">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span>
|
||||
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? `${h.first_name} ${h.last_name}` : "Unbekannt"}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||
{h.event_type}
|
||||
</span>
|
||||
@@ -305,7 +347,7 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
||||
{(history).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import type { UnitOption } from "@/components/positions/CreatePositionModal";
|
||||
import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
|
||||
import { daysBetweenIso, toIsoDate } from "@/lib/format";
|
||||
import { daysBetweenIso } from "@/lib/format";
|
||||
import { loadOrgMaps } from "@/lib/org";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
export default async function PositionsPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Teams are only needed for the "Position ausschreiben" dialog's team
|
||||
// select. The division/department/team headcount overview this page used
|
||||
// to render was dropped, and with it the two employee-wide aggregation
|
||||
// queries that fed it.
|
||||
const [openPositions, { data: teams }] = await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("teams").select("*").order("name"),
|
||||
const { openPositions, orgMaps, chiefRows } = await withUser(await currentUserId(), async (tx) => {
|
||||
const [openPositions, orgMaps, chiefRows] = await Promise.all([
|
||||
loadOpenPositions(tx),
|
||||
loadOrgMaps(tx),
|
||||
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
|
||||
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
|
||||
// Fehlermeldung nach dem Absenden.
|
||||
tx.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null).execute(),
|
||||
]);
|
||||
return { openPositions, orgMaps, chiefRows };
|
||||
});
|
||||
|
||||
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(toIsoDate(p.created_at)) }));
|
||||
const withChief = new Set(chiefRows.map((r) => r.org_unit_id));
|
||||
const units: UnitOption[] = orgMaps.unitList.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
unit_type: u.unit_type,
|
||||
depth: orgMaps.depthOf.get(u.id) ?? 0,
|
||||
hasChief: withChief.has(u.id),
|
||||
}));
|
||||
|
||||
return <PositionsPageClient openPositions={openPositionsWithDays} teams={teams ?? []} />;
|
||||
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(p.vacantSince) }));
|
||||
|
||||
return <PositionsPageClient openPositions={openPositionsWithDays} units={units} />;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
totalForRows,
|
||||
} from "@/lib/reports";
|
||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
type SearchParams = {
|
||||
mode?: string;
|
||||
@@ -35,7 +36,6 @@ type SearchParams = {
|
||||
|
||||
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams;
|
||||
const supabase = await createClient();
|
||||
const mode = parseMode(params.mode);
|
||||
|
||||
// Both modes are parsed up front so the data load can start before
|
||||
@@ -55,11 +55,17 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
// in, so all three go out together. Against a hosted database a round trip
|
||||
// costs about as much as the query itself, which made this page's three
|
||||
// sequential waves its dominant cost.
|
||||
const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([
|
||||
loadOrgLookups(supabase),
|
||||
supabase.auth.getUser(),
|
||||
const userId = await currentUserId();
|
||||
|
||||
// Alles in einer Transaktion — dort gilt der Sitzungskontext, und der
|
||||
// Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei
|
||||
// Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste
|
||||
// Teil dieser Seite war.
|
||||
const { lookups, divisions, locations, events, employees, savedReports } = await withUser(userId, async (tx) => {
|
||||
const [{ lookups, divisions, locations }, events, employees, savedReports] = await Promise.all([
|
||||
loadOrgLookups(tx),
|
||||
mode === "events"
|
||||
? loadEventHistory(supabase, {
|
||||
? loadEventHistory(tx, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
@@ -68,7 +74,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
mode === "snapshot"
|
||||
? loadSnapshotEmployees(supabase, {
|
||||
? loadSnapshotEmployees(tx, {
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
status: params.status,
|
||||
@@ -76,13 +82,17 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
asOf,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
userId
|
||||
? tx
|
||||
.selectFrom("saved_reports")
|
||||
.select(["id", "name", "config"])
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("created_at", "desc")
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
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: [] };
|
||||
return { lookups, divisions, locations, events, employees, savedReports };
|
||||
});
|
||||
|
||||
if (mode === "events") {
|
||||
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
|
||||
@@ -100,7 +110,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
recordCount={events.length}
|
||||
divisions={divisions}
|
||||
locations={locations}
|
||||
savedReports={savedReports ?? []}
|
||||
savedReports={savedReports}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -127,7 +137,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
recordCount={employees.length}
|
||||
divisions={divisions}
|
||||
locations={locations}
|
||||
savedReports={savedReports ?? []}
|
||||
savedReports={savedReports}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { login, logout } from "@/actions/auth";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { CONTROL_CLASS } from "@/components/ui/Field";
|
||||
import { EntraSignInButton } from "@/components/auth/EntraSignInButton";
|
||||
import { logout, signInWithEntra } from "@/actions/auth";
|
||||
|
||||
// The query string is attacker-controlled, so the login page renders a message
|
||||
// looked up by code rather than whatever text ?error= carries. Reflecting the
|
||||
// raw parameter let anyone put arbitrary wording ("Ihr Konto wurde gesperrt,
|
||||
// rufen Sie …") on the real, correctly-branded sign-in screen.
|
||||
// rufen Sie …") on the real, correctly-branded sign-in screen. Dasselbe gilt
|
||||
// für die Fehlertexte, die Entra im Rückweg mitschickt.
|
||||
const ERROR_MESSAGES = {
|
||||
no_hr_access: "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
|
||||
invalid_credentials: "E-Mail oder Passwort ist falsch.",
|
||||
no_hr_access: {
|
||||
title: "Kein HR-Zugriff",
|
||||
body: "Ihr Firmenkonto ist bekannt, aber nicht für die Personalverwaltung freigeschaltet. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.",
|
||||
},
|
||||
sso_failed: {
|
||||
title: "Anmeldung fehlgeschlagen",
|
||||
body: "Die Anmeldung über das Firmenkonto konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type ErrorCode = keyof typeof ERROR_MESSAGES;
|
||||
@@ -19,60 +25,119 @@ type LoginPageProps = {
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const params = await searchParams;
|
||||
const code = params.error && Object.hasOwn(ERROR_MESSAGES, params.error) ? (params.error as ErrorCode) : null;
|
||||
// Ein unbekannter Code wird nicht verschluckt, sondern auf die allgemeine
|
||||
// Meldung abgebildet: Auth.js schickt bei einem Fehlschlag seine eigenen
|
||||
// Codes („Configuration", „AccessDenied", „OAuthCallbackError" …), und ohne
|
||||
// diese Abbildung stünde man vor einer Anmeldeseite, die so tut, als wäre
|
||||
// nichts gewesen. Angezeigt wird trotzdem nur eigener Text — der Parameter
|
||||
// selbst kommt nie auf die Seite.
|
||||
const code = params.error
|
||||
? Object.hasOwn(ERROR_MESSAGES, params.error)
|
||||
? (params.error as ErrorCode)
|
||||
: "sso_failed"
|
||||
: null;
|
||||
const error = code ? ERROR_MESSAGES[code] : null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
|
||||
<div className="w-full max-w-sm rounded border border-border bg-white p-8 shadow-sm">
|
||||
<h1 className="text-xl font-extrabold text-ink">Alpenwerk HR</h1>
|
||||
<p className="mt-1 text-sm text-ink-muted">Melden Sie sich mit Ihrem Firmenkonto an.</p>
|
||||
// dvh statt vh: auf iOS zählt vh die Adressleiste mit, wodurch die Karte
|
||||
// im ersten Moment unter dem Faltenrand sitzt.
|
||||
<div className="min-h-dvh lg:grid lg:grid-cols-[1.05fr_1fr]">
|
||||
<BrandPanel />
|
||||
|
||||
<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 && (
|
||||
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
{error}
|
||||
<div role="alert" className="mt-6 rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3">
|
||||
<p className="text-sm font-bold text-danger-text">{error.title}</p>
|
||||
<p className="mt-1 text-sm text-danger-text/90">{error.body}</p>
|
||||
{code === "no_hr_access" && (
|
||||
<form action={logout} className="mt-2">
|
||||
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
|
||||
Abmelden und mit anderem Konto versuchen
|
||||
<form action={logout} className="mt-3">
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded text-xs font-semibold text-danger-text underline underline-offset-2 hover:no-underline
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger-text"
|
||||
>
|
||||
Abmelden und mit einem anderen Konto versuchen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="mt-6 flex flex-col gap-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1 block text-sm font-semibold text-ink">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
className={CONTROL_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1 block text-sm font-semibold text-ink">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className={CONTROL_CLASS}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" fullWidth className="mt-2">
|
||||
Anmelden
|
||||
</Button>
|
||||
<form action={signInWithEntra} className="mt-7">
|
||||
<EntraSignInButton />
|
||||
</form>
|
||||
|
||||
<p className="mt-6 border-t border-border-subtle pt-5 text-xs leading-relaxed text-ink-muted">
|
||||
Die Anmeldung allein erteilt keinen Zugriff. HR-Rechte vergibt die Personalabteilung — bis dahin bleiben alle
|
||||
Personaldaten verschlossen.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BrandPanel() {
|
||||
return (
|
||||
<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 className="relative max-w-md">
|
||||
<p className="text-3xl font-extrabold leading-tight tracking-tight text-white">
|
||||
Die Organisation, so wie sie heute wirklich aussieht.
|
||||
</p>
|
||||
<p className="mt-4 text-sm leading-relaxed text-white/70">
|
||||
Stammdaten, Planstellen und Berichtslinien der Alpenwerk Industrie GmbH — jederzeit auch zu einem beliebigen
|
||||
Stichtag.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="relative text-xs text-white/50">Interne Anwendung · Zugriff nur für die Personalabteilung</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
9
app/api/auth/[...nextauth]/route.ts
Normal file
9
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
// Der Rückweg aus Entra ID und die Endpunkte für An- und Abmeldung.
|
||||
//
|
||||
// Tritt an die Stelle von app/auth/callback/route.ts: den Tausch des
|
||||
// Einmal-Codes gegen eine Sitzung, die Prüfung von `state` und `nonce` und
|
||||
// das Setzen des Cookies macht jetzt Auth.js. Die Rückruf-Adresse in der
|
||||
// Entra-Anwendungsregistrierung ändert sich dadurch — siehe docs/entra-sso.md.
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
import { asSystem } from "@/lib/db";
|
||||
import { callFunction } from "@/lib/db/rpc";
|
||||
|
||||
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
|
||||
// ändern with a future "Wirksam ab" date) once their date has arrived — see
|
||||
@@ -13,13 +14,15 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { data, error } = await supabase.rpc("apply_due_pending_changes");
|
||||
|
||||
if (error) {
|
||||
console.error("apply_due_pending_changes failed:", error);
|
||||
// Kein privilegierter Zugang mehr: derselbe Datenbankbenutzer ohne
|
||||
// BYPASSRLS wie überall. apply_due_pending_changes ist SECURITY DEFINER
|
||||
// und prüft selbst, was sie tut — der Dienstschlüssel, der RLS aushebelte,
|
||||
// ist damit entfallen.
|
||||
try {
|
||||
const applied = await asSystem((tx) => callFunction(tx, "apply_due_pending_changes"));
|
||||
return NextResponse.json({ applied });
|
||||
} catch (err) {
|
||||
console.error("apply_due_pending_changes failed:", err);
|
||||
return NextResponse.json({ error: "Interner Fehler." }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ applied: data });
|
||||
}
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { statusLabel } from "@/lib/absence";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { subtreeOf } from "@/lib/org";
|
||||
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
|
||||
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
|
||||
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
||||
import { requireHrUser } from "@/lib/supabase/auth";
|
||||
import { fetchAllRows } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
|
||||
// ü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
|
||||
// pivot report groups by. Respects the same division/location/status/
|
||||
@@ -16,9 +25,8 @@ type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
// filtering happens against the *derived* status as of that date rather
|
||||
// than the live `status` column — see deriveStatusAsOf.
|
||||
export async function GET(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const denied = await requireHrUser(supabase);
|
||||
if (denied) return denied;
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||
@@ -32,24 +40,58 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
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() {
|
||||
let query = supabase.from("employees").select("*").order("last_name").order("id");
|
||||
if (filters.division) query = query.eq("division_id", filters.division);
|
||||
if (filters.location) query = query.eq("location_id", filters.location);
|
||||
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
||||
if (!asOf) query = query.in("status", statuses);
|
||||
return query;
|
||||
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
|
||||
if (filters.location) q = q.where("location_id", "=", filters.location);
|
||||
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
|
||||
if (!asOf) q = q.where("status", "in", statuses);
|
||||
return q;
|
||||
}
|
||||
|
||||
const [employees, { lookups }, allEmployees, dependentsCounts] = await Promise.all([
|
||||
fetchAllRows(employeeQuery),
|
||||
loadOrgLookups(supabase),
|
||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
|
||||
loadDependentsCounts(supabase),
|
||||
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
|
||||
employeeQuery().execute(),
|
||||
loadOrgLookups(tx),
|
||||
tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(),
|
||||
loadDependentsCounts(tx),
|
||||
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 rows = asOf ? employees.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : employees;
|
||||
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
|
||||
// Export für "Produktion" nur die Bereichsleitung.
|
||||
const allowedUnits = filters.division ? new Set(subtreeOf(orgMaps, filters.division)) : null;
|
||||
|
||||
const 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 filename = exportFilename("mitarbeiter-export", format);
|
||||
|
||||
@@ -81,14 +123,14 @@ function employeeExportColumns(
|
||||
{ header: "Wohnsitzland", get: (e) => e.address_country },
|
||||
{ header: "E-Mail", get: (e) => e.email },
|
||||
{ header: "Telefon", get: (e) => e.phone },
|
||||
{ header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" },
|
||||
{ header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") },
|
||||
{ header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") },
|
||||
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
|
||||
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
|
||||
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },
|
||||
{ header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
|
||||
{ header: "Position", get: (e) => e.job_title },
|
||||
{ header: "Vorgesetzte:r", get: (e) => (e.manager_id ? (managerName.get(e.manager_id) ?? "") : "") },
|
||||
{ header: "Führungskraft", get: (e) => e.is_lead },
|
||||
{ header: "Org-Level", get: (e) => e.org_level },
|
||||
{ header: "Planstelle", get: (e) => e.position_number },
|
||||
{ header: "Leitungsplanstelle", get: (e) => e.is_chief },
|
||||
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
|
||||
{ header: "Wochenstunden", get: (e) => e.weekly_hours },
|
||||
// work_days is stored in click order (see RoleEmploymentFields), not
|
||||
|
||||
@@ -2,25 +2,25 @@ import { NextResponse, type NextRequest } from "next/server";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
|
||||
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
|
||||
import { requireHrUser } from "@/lib/supabase/auth";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
// Full raw event-log dump — one row per employee_history entry in the
|
||||
// selected period (default: current year), every event type unless one is
|
||||
// picked, org columns resolved from each affected employee's current
|
||||
// placement (see loadEventHistory).
|
||||
export async function GET(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const denied = await requireHrUser(supabase);
|
||||
if (denied) return denied;
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||
const eventType = parseEventType(params.get("eventType"));
|
||||
|
||||
const { lookups, events } = await withUser(gate.userId, async (tx) => {
|
||||
const [{ lookups }, events] = await Promise.all([
|
||||
loadOrgLookups(supabase),
|
||||
loadEventHistory(supabase, {
|
||||
loadOrgLookups(tx),
|
||||
loadEventHistory(tx, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
@@ -28,6 +28,8 @@ export async function GET(request: NextRequest) {
|
||||
to: parseEventDateParam(params.get("to")),
|
||||
}),
|
||||
]);
|
||||
return { lookups, events };
|
||||
});
|
||||
|
||||
const columns = eventExportColumns(lookups);
|
||||
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);
|
||||
@@ -44,9 +46,9 @@ function eventExportColumns(lookups: OrgLookups): ExportColumn<ReportEvent>[] {
|
||||
{ header: "Vorname", get: (e) => e.first_name },
|
||||
{ header: "Nachname", get: (e) => e.last_name },
|
||||
{ header: "Position", get: (e) => e.job_title },
|
||||
{ header: "Bereich", get: (e) => lookups.divisionName.get(e.division_id) ?? "" },
|
||||
{ header: "Abteilung", get: (e) => (e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "") : "") },
|
||||
{ header: "Team", get: (e) => (e.team_id ? (lookups.teamName.get(e.team_id) ?? "") : "") },
|
||||
{ header: "Bereich", get: (e) => (e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "") : "") },
|
||||
{ header: "Abteilung", get: (e) => (e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "") : "") },
|
||||
{ header: "Team", get: (e) => (e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "") : "") },
|
||||
{ header: "Standort", get: (e) => lookups.locationName.get(e.location_id) ?? "" },
|
||||
{ header: "Beschreibung", get: (e) => e.description },
|
||||
];
|
||||
|
||||
@@ -24,23 +24,24 @@ import {
|
||||
type ReportRow,
|
||||
} from "@/lib/reports";
|
||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||
import { requireHrUser } from "@/lib/supabase/auth";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
// Exports exactly the pivot table currently on screen (same mode/measure or
|
||||
// event-type/group/split/filters, read from the query string the client
|
||||
// already keeps in the URL) as a flat table — one row per group, one column
|
||||
// per split value if a split is active.
|
||||
export async function GET(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
const denied = await requireHrUser(supabase);
|
||||
if (denied) return denied;
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||
const mode = parseMode(params.get("mode"));
|
||||
const { lookups } = await loadOrgLookups(supabase);
|
||||
|
||||
// Eine Transaktion für Nachschlagewerte und Daten: dort gilt der
|
||||
// Sitzungskontext, und beide sehen denselben Lesestand.
|
||||
const { rows, columns, filenameBase } = await withUser(gate.userId, async (tx) => {
|
||||
const { lookups } = await loadOrgLookups(tx);
|
||||
let rows: ReportRow[];
|
||||
let columns: ExportColumn<ReportRow>[];
|
||||
let filenameBase: string;
|
||||
@@ -49,7 +50,7 @@ export async function GET(request: NextRequest) {
|
||||
const group = parseEventGroupDimension(params.get("group"));
|
||||
const split = parseEventSplitDimension(params.get("split"));
|
||||
const eventType = parseEventType(params.get("eventType"));
|
||||
const events = await loadEventHistory(supabase, {
|
||||
const events = await loadEventHistory(tx, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
@@ -64,7 +65,7 @@ export async function GET(request: NextRequest) {
|
||||
const group = parseGroupDimension(params.get("group"));
|
||||
const split = parseSplitDimension(params.get("split"));
|
||||
const asOf = parseIsoDateParam(params.get("asOf"));
|
||||
const employees = await loadSnapshotEmployees(supabase, {
|
||||
const employees = await loadSnapshotEmployees(tx, {
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
status: params.get("status") ?? undefined,
|
||||
@@ -76,7 +77,11 @@ export async function GET(request: NextRequest) {
|
||||
filenameBase = `bericht-${measure}-${group}`;
|
||||
}
|
||||
|
||||
return { rows, columns, filenameBase };
|
||||
});
|
||||
|
||||
const filename = exportFilename(filenameBase, format);
|
||||
|
||||
const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns);
|
||||
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
|
||||
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.
|
||||
|
||||
131
app/api/import/route.ts
Normal file
131
app/api/import/route.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
96
app/api/import/template/route.ts
Normal file
96
app/api/import/template/route.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||
import { exportFilename, exportResponseHeaders } from "@/lib/export";
|
||||
import { BLAETTER } from "@/lib/import/schema";
|
||||
|
||||
// Die Vorlage entsteht aus demselben Schema wie die Prüfung.
|
||||
//
|
||||
// Das ist der Punkt: eine von Hand gepflegte Beispieldatei läuft dem Code
|
||||
// hinterher, und dann verlangt die Vorlage eine Spalte, die es nicht mehr
|
||||
// gibt — oder umgekehrt. Hier kann das nicht passieren; kommt in schema.ts
|
||||
// eine Spalte dazu, steht sie beim nächsten Herunterladen drin.
|
||||
|
||||
export async function GET() {
|
||||
const gate = await requireHrUser();
|
||||
if ("denied" in gate) return gate.denied;
|
||||
|
||||
const mappe = new ExcelJS.Workbook();
|
||||
mappe.creator = "Alpenwerk HR";
|
||||
mappe.created = new Date();
|
||||
|
||||
const hinweise = mappe.addWorksheet("Hinweise");
|
||||
hinweise.columns = [
|
||||
{ header: "Blatt", width: 16 },
|
||||
{ header: "Spalte", width: 26 },
|
||||
{ header: "Pflicht", width: 9 },
|
||||
{ header: "Format", width: 30 },
|
||||
{ header: "Hinweis", width: 70 },
|
||||
];
|
||||
hinweise.getRow(1).font = { bold: true };
|
||||
|
||||
const formatText = (typ: (typeof BLAETTER)[number]["spalten"][number]["typ"]): string => {
|
||||
switch (typ.art) {
|
||||
case "datum":
|
||||
return "Datum (31.12.2026)";
|
||||
case "zahl":
|
||||
return "Zahl (38,5)";
|
||||
case "ganzzahl":
|
||||
return "Ganze Zahl";
|
||||
case "janein":
|
||||
return "ja / nein";
|
||||
case "liste":
|
||||
return typ.werte ? `Mehrere mit Semikolon aus: ${typ.werte.join(", ")}` : "Mehrere mit Semikolon";
|
||||
case "auswahl":
|
||||
return typ.werte.join(" | ");
|
||||
default:
|
||||
return "Text";
|
||||
}
|
||||
};
|
||||
|
||||
for (const schema of BLAETTER) {
|
||||
hinweise.addRow([schema.name, "", "", "", schema.zweck]).font = { bold: true };
|
||||
for (const s of schema.spalten) {
|
||||
hinweise.addRow([schema.name, s.name, s.pflicht ? "ja" : "", formatText(s.typ), s.hinweis]);
|
||||
}
|
||||
hinweise.addRow([]);
|
||||
}
|
||||
|
||||
for (const schema of BLAETTER) {
|
||||
const blatt = mappe.addWorksheet(schema.name);
|
||||
blatt.columns = schema.spalten.map((s) => ({
|
||||
header: s.name,
|
||||
width: Math.max(12, Math.min(28, s.name.length + 4)),
|
||||
}));
|
||||
|
||||
const kopf = blatt.getRow(1);
|
||||
kopf.font = { bold: true };
|
||||
kopf.eachCell((zelle, i) => {
|
||||
const spalte = schema.spalten[i - 1];
|
||||
if (!spalte) return;
|
||||
// Pflichtspalten sichtbar markieren — sonst ist die erste Rückmeldung
|
||||
// eine Fehlerliste statt eines Hinweises beim Ausfüllen.
|
||||
if (spalte.pflicht) {
|
||||
zelle.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFFDE7EF" } };
|
||||
}
|
||||
const teile = [spalte.pflicht ? "Pflichtfeld." : "Optional.", formatText(spalte.typ), spalte.hinweis].filter(Boolean);
|
||||
zelle.note = teile.join("\n");
|
||||
});
|
||||
|
||||
// Eine Beispielzeile. Alles als Text, damit Excel nicht selbst
|
||||
// interpretiert — der Leser deutet die Werte ohnehin.
|
||||
const beispiel = schema.spalten.map((s) => s.beispiel);
|
||||
if (beispiel.some(Boolean)) {
|
||||
const zeile = blatt.addRow(beispiel);
|
||||
zeile.font = { italic: true, color: { argb: "FF8A8A8A" } };
|
||||
zeile.eachCell((z) => {
|
||||
z.numFmt = "@";
|
||||
});
|
||||
}
|
||||
blatt.views = [{ state: "frozen", ySplit: 1 }];
|
||||
}
|
||||
|
||||
const puffer = await mappe.xlsx.writeBuffer();
|
||||
const dateiname = exportFilename("import-vorlage", "xlsx");
|
||||
return new NextResponse(new Blob([puffer as BlobPart]), { headers: exportResponseHeaders(dateiname, "xlsx") });
|
||||
}
|
||||
65
auth.ts
Normal file
65
auth.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
140
components/audit/AuditDetail.tsx
Normal file
140
components/audit/AuditDetail.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
32
components/auth/EntraSignInButton.tsx
Normal file
32
components/auth/EntraSignInButton.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
// Eigene Schaltfläche statt der Button-Komponente: Microsoft gibt für „Sign in
|
||||
// with Microsoft" Fläche, Schrift und Logo vor, und eine magentafarbene
|
||||
// Variante wäre nicht nur regelwidrig, sondern auch irreführend — sie sähe aus
|
||||
// wie eine Aktion *in* dieser Anwendung, während sie in Wirklichkeit auf eine
|
||||
// fremde Anmeldeseite springt.
|
||||
export function EntraSignInButton() {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
aria-busy={pending || undefined}
|
||||
className="inline-flex w-full items-center justify-center gap-3 rounded-md border border-[#8c8c8c] bg-white px-4 py-3
|
||||
text-sm font-semibold text-[#5e5e5e] transition-colors hover:bg-[#f3f3f3]
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500
|
||||
disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<svg viewBox="0 0 21 21" className="h-[18px] w-[18px] shrink-0" aria-hidden="true">
|
||||
<rect x="1" y="1" width="9" height="9" fill="#f25022" />
|
||||
<rect x="11" y="1" width="9" height="9" fill="#7fba00" />
|
||||
<rect x="1" y="11" width="9" height="9" fill="#00a4ef" />
|
||||
<rect x="11" y="11" width="9" height="9" fill="#ffb900" />
|
||||
</svg>
|
||||
{pending ? "Weiterleitung zu Microsoft…" : "Mit Firmenkonto anmelden"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Avatar } from "@/components/ui/Avatar";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusChip } from "@/components/ui/StatusChip";
|
||||
import { fmtFullName, tenure } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
|
||||
import { KarenzPanel } from "./panels/KarenzPanel";
|
||||
@@ -21,42 +22,37 @@ import { StammdatenTab } from "./tabs/StammdatenTab";
|
||||
import { VertragTab } from "./tabs/VertragTab";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
type NoteRow = Database["public"]["Tables"]["employee_notes"]["Row"];
|
||||
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
|
||||
type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean };
|
||||
/** Die Planstelle, die die Person heute innehat. */
|
||||
type PlacementInfo = { positionNumber: string; jobTitle: string; isChief: boolean; current: boolean };
|
||||
|
||||
type EmployeeDetailProps = {
|
||||
employee: EmployeeRow;
|
||||
placement: PlacementInfo | null;
|
||||
breadcrumb: string;
|
||||
manager: MiniEmployee | null;
|
||||
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
||||
formalManager: MiniEmployee | null;
|
||||
directReports: MiniEmployee[];
|
||||
history: HistoryRow[];
|
||||
dependents: Dependent[];
|
||||
notes: NoteRow[];
|
||||
divisions: Division[];
|
||||
departments: Department[];
|
||||
teams: Team[];
|
||||
locations: Location[];
|
||||
openPositions: OpenPosition[];
|
||||
openPositions: OpenPositionResolved[];
|
||||
};
|
||||
|
||||
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
|
||||
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
|
||||
|
||||
export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
const { employee, manager, directReports, history, dependents, notes, divisions, departments, teams, locations } = props;
|
||||
const { employee, placement, breadcrumb, manager, formalManager, directReports, history, dependents, notes, locations, openPositions } = props;
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
|
||||
const [panel, setPanel] = useState<PanelType>(null);
|
||||
|
||||
const division = divisions.find((d) => d.id === employee.division_id);
|
||||
const team = employee.team_id ? teams.find((t) => t.id === employee.team_id) : undefined;
|
||||
const department = team ? departments.find((d) => d.id === team.department_id) : undefined;
|
||||
const breadcrumb = [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–";
|
||||
const location = locations.find((l) => l.id === employee.location_id);
|
||||
|
||||
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
|
||||
@@ -79,8 +75,17 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
</h2>
|
||||
<StatusChip status={employee.status} entryDate={employee.entry_date} absenceType={employee.absence_type} />
|
||||
</div>
|
||||
<p className="text-sm text-ink-body">{employee.job_title}</p>
|
||||
<p className="text-xs text-ink-muted">{breadcrumb}</p>
|
||||
<p className="text-sm text-ink-body">{placement?.jobTitle ?? employee.job_title}</p>
|
||||
<p className="text-xs text-ink-muted">
|
||||
{breadcrumb}
|
||||
{placement && (
|
||||
<>
|
||||
{" · Planstelle "}
|
||||
{placement.positionNumber}
|
||||
{placement.isChief && " (Leitung)"}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-muted">
|
||||
Pers.-Nr. {employee.personnel_number}
|
||||
{employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
|
||||
@@ -142,7 +147,13 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} dependents={dependents} />}
|
||||
{tab === "Vertrag" && <VertragTab employee={employee} />}
|
||||
{tab === "Organisation" && (
|
||||
<OrganisationTab employeeId={employee.id} manager={manager} directReports={directReports} breadcrumb={breadcrumb} />
|
||||
<OrganisationTab
|
||||
employeeId={employee.id}
|
||||
manager={manager}
|
||||
formalManager={formalManager}
|
||||
directReports={directReports}
|
||||
breadcrumb={breadcrumb}
|
||||
/>
|
||||
)}
|
||||
{tab === "Historie" && <HistorieTab history={history} />}
|
||||
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||||
@@ -152,10 +163,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
open={panel === "transfer"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
divisions={divisions}
|
||||
departments={departments}
|
||||
teams={teams}
|
||||
currentTeamId={employee.team_id}
|
||||
openPositions={openPositions}
|
||||
/>
|
||||
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
|
||||
|
||||
@@ -6,7 +6,9 @@ import { FILTER_SELECT_CLASS } from "@/components/ui/Field";
|
||||
import { SearchInput } from "@/components/ui/SearchInput";
|
||||
|
||||
type EmployeeFiltersProps = {
|
||||
divisions: { id: string; name: string }[];
|
||||
/** Der ganze Baum, in Tiefensuche-Reihenfolge. */
|
||||
units: { id: string; name: string; unit_type: string }[];
|
||||
depthOf: Map<string, number>;
|
||||
locations: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
@@ -22,7 +24,7 @@ const STATUS_OPTIONS = [
|
||||
{ value: "Ausgetreten", label: "Ausgetreten" },
|
||||
] as const;
|
||||
|
||||
export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) {
|
||||
export function EmployeeFilters({ units, depthOf, locations }: EmployeeFiltersProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -55,16 +57,24 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps)
|
||||
{/* aria-label rather than a visible label: the filter bar is a single
|
||||
horizontal row, and each select's first option already names it on
|
||||
screen. */}
|
||||
{/* Der ganze Baum, nicht nur die oberste Ebene: die Auswahl greift
|
||||
jeweils auf die Einheit *und alles darunter*, weshalb sich damit
|
||||
auch nach einer einzelnen Abteilung oder einem Team filtern lässt.
|
||||
Eingerückt statt gruppiert, weil optgroup keine Verschachtelung
|
||||
kennt und die Tiefe hier beliebig ist. */}
|
||||
<select
|
||||
aria-label="Nach Bereich filtern"
|
||||
aria-label="Nach Organisationseinheit filtern"
|
||||
defaultValue={searchParams.get("division") ?? ""}
|
||||
onChange={(e) => updateParam("division", e.target.value)}
|
||||
className={FILTER_SELECT_CLASS}
|
||||
>
|
||||
<option value="">Alle Bereiche</option>
|
||||
{divisions.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
<option value="">Alle Einheiten</option>
|
||||
{units
|
||||
.filter((u) => u.unit_type !== "Gesellschaft")
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{" ".repeat(Math.max(0, (depthOf.get(u.id) ?? 1) - 1) * 3)}
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -7,48 +7,51 @@ import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
|
||||
type TransferPanelProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
employee: EmployeeRow;
|
||||
divisions: Division[];
|
||||
departments: Department[];
|
||||
teams: Team[];
|
||||
currentTeamId: string | null;
|
||||
openPositions: OpenPositionResolved[];
|
||||
};
|
||||
|
||||
export function TransferPanel({ open, onClose, employee, divisions, departments, teams, currentTeamId }: TransferPanelProps) {
|
||||
// Eine Versetzung ist der Wechsel auf eine andere Planstelle — nicht mehr die
|
||||
// Angabe eines Zielteams samt frei getipptem Titel. Bereich, Abteilung und
|
||||
// Team ergeben sich aus der Einheit der Zielplanstelle, die neue Tätigkeit aus
|
||||
// ihrem Job. Damit kann eine Versetzung gar nicht erst irgendwo landen, wo es
|
||||
// keine Stelle gibt.
|
||||
export function TransferPanel({ open, onClose, employee, openPositions }: TransferPanelProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [effectiveDate, setEffectiveDate] = useState("");
|
||||
const [divisionId, setDivisionId] = useState(employee.division_id);
|
||||
const [teamId, setTeamId] = useState(currentTeamId ?? "");
|
||||
const [newTitle, setNewTitle] = useState("");
|
||||
const [positionId, setPositionId] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const teamsInDivision = useMemo(() => {
|
||||
const deptIds = new Set(departments.filter((d) => d.division_id === divisionId).map((d) => d.id));
|
||||
return teams.filter((t) => deptIds.has(t.department_id));
|
||||
}, [departments, teams, divisionId]);
|
||||
const options = useMemo(
|
||||
() =>
|
||||
openPositions
|
||||
.slice()
|
||||
.sort((a, b) => a.orgLabel.localeCompare(b.orgLabel, "de") || a.title.localeCompare(b.title, "de"))
|
||||
.map((p) => ({ value: p.id, label: `${p.orgLabel} · ${p.title} (${p.position_number})` })),
|
||||
[openPositions]
|
||||
);
|
||||
|
||||
const selected = openPositions.find((p) => p.id === positionId);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!effectiveDate || !teamId) {
|
||||
showToast("Bitte Datum und Zielteam angeben.", "error");
|
||||
if (!effectiveDate || !positionId) {
|
||||
showToast("Bitte Datum und Zielplanstelle angeben.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await transferEmployee({
|
||||
employee_id: employee.id,
|
||||
effective_date: effectiveDate,
|
||||
new_team_id: teamId,
|
||||
new_title: newTitle || undefined,
|
||||
target_position_id: positionId,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
@@ -71,7 +74,7 @@ export function TransferPanel({ open, onClose, employee, divisions, departments,
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
<Button onClick={handleSubmit} pending={pending} disabled={options.length === 0}>
|
||||
Versetzen
|
||||
</Button>
|
||||
</>
|
||||
@@ -79,31 +82,33 @@ export function TransferPanel({ open, onClose, employee, divisions, departments,
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField label="Wirksam ab" required type="date" value={effectiveDate} onChange={setEffectiveDate} />
|
||||
{options.length === 0 ? (
|
||||
<p className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
||||
Es gibt derzeit keine unbesetzte Planstelle. Eine Versetzung setzt eine freie Zielplanstelle voraus — legen Sie
|
||||
zuerst unter „Positionen“ eine an.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<SelectField
|
||||
label="Neuer Bereich"
|
||||
label="Zielplanstelle"
|
||||
required
|
||||
value={divisionId}
|
||||
onChange={(v) => {
|
||||
setDivisionId(v);
|
||||
setTeamId("");
|
||||
}}
|
||||
options={divisions.map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Neues Team"
|
||||
required
|
||||
value={teamId}
|
||||
onChange={setTeamId}
|
||||
value={positionId}
|
||||
onChange={setPositionId}
|
||||
placeholder="Bitte wählen…"
|
||||
options={teamsInDivision.map((t) => ({ value: t.id, label: t.name }))}
|
||||
/>
|
||||
<TextField
|
||||
label="Neuer Titel (optional)"
|
||||
value={newTitle}
|
||||
onChange={setNewTitle}
|
||||
placeholder={employee.job_title}
|
||||
hint="Die neue Führungskraft wird automatisch anhand des Zielteams bestimmt."
|
||||
options={options}
|
||||
/>
|
||||
{selected && (
|
||||
<div className="rounded border border-border bg-surface p-3 text-sm text-ink-body">
|
||||
<div className="font-semibold text-ink">{selected.title}</div>
|
||||
<div className="text-xs text-ink-muted">{selected.orgLabel}</div>
|
||||
<div className="mt-1 text-xs text-ink-muted">
|
||||
{selected.is_chief ? "Leitungsplanstelle" : "Mitarbeiterplanstelle"}
|
||||
{selected.managerName ? ` · berichtet an ${selected.managerName}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SlideOver>
|
||||
);
|
||||
|
||||
@@ -8,11 +8,13 @@ type MiniEmployee = { id: string; first_name: string; last_name: string; job_tit
|
||||
type OrganisationTabProps = {
|
||||
employeeId: string;
|
||||
manager: MiniEmployee | null;
|
||||
/** Nur gesetzt, wenn die zuständige Leitung abwesend ist und vertreten wird. */
|
||||
formalManager: MiniEmployee | null;
|
||||
directReports: MiniEmployee[];
|
||||
breadcrumb: string;
|
||||
};
|
||||
|
||||
export function OrganisationTab({ employeeId, manager, directReports, breadcrumb }: OrganisationTabProps) {
|
||||
export function OrganisationTab({ employeeId, manager, formalManager, directReports, breadcrumb }: OrganisationTabProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
@@ -30,7 +32,15 @@ export function OrganisationTab({ employeeId, manager, directReports, breadcrumb
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Führungskraft</h3>
|
||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
{formalManager ? "Führungskraft (Vertretung)" : "Führungskraft"}
|
||||
</h3>
|
||||
{formalManager && (
|
||||
<p className="mb-2 text-xs text-ink-muted">
|
||||
Zuständig ist {formalManager.first_name} {formalManager.last_name}; während der Abwesenheit übernimmt die nächste
|
||||
besetzte Ebene.
|
||||
</p>
|
||||
)}
|
||||
{manager ? (
|
||||
<Link href={`/employees/${manager.id}`} className="flex w-fit items-center gap-3 rounded border border-border p-3 hover:bg-surface">
|
||||
<Avatar firstName={manager.first_name} lastName={manager.last_name} />
|
||||
|
||||
@@ -58,7 +58,11 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
isValidSvnr(draft.svNummer, draft.birthDate || null);
|
||||
|
||||
const stepValid = [
|
||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId) && svNummerOk,
|
||||
// E-Mail gehört zu den Pflichtfeldern, weil die Spalte NOT NULL ist. Ohne
|
||||
// die Prüfung hier bricht erst die Datenbank ab — am Ende des vierten
|
||||
// Schritts, nach allen Eingaben.
|
||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId && draft.email.trim()) &&
|
||||
svNummerOk,
|
||||
Boolean(draft.positionId && draft.besetzung),
|
||||
Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||
true,
|
||||
@@ -86,6 +90,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
gender: draft.gender,
|
||||
birth_date: draft.birthDate,
|
||||
sv_nummer: draft.svNummer || undefined,
|
||||
email: draft.email.trim(),
|
||||
phone: draft.phone || undefined,
|
||||
position_id: draft.positionId,
|
||||
location_id: draft.locationId,
|
||||
|
||||
@@ -37,7 +37,7 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
||||
birthDate={draft.birthDate || null}
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="E-Mail (privat)" type="email" value={draft.email} onChange={(email) => update({ email })} />
|
||||
<TextField label="E-Mail" required type="email" value={draft.email} onChange={(email) => update({ email })} />
|
||||
<TextField label="Telefon" type="tel" value={draft.phone} onChange={(phone) => update({ phone })} />
|
||||
</div>
|
||||
<SelectField
|
||||
|
||||
210
components/import/ImportWorkbench.tsx
Normal file
210
components/import/ImportWorkbench.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { CARD_CLASS } from "@/components/ui/Card";
|
||||
|
||||
// Der Ablauf hat bewusst zwei Schritte: prüfen, dann übernehmen.
|
||||
//
|
||||
// Ein Import ist nicht rückgängig zu machen. Wer 800 Zeilen schickt, soll
|
||||
// vorher sehen, was entstehen würde — und bei einem Fehler die Zeilennummer
|
||||
// lesen, nicht „Import fehlgeschlagen".
|
||||
|
||||
type Befund = { blatt: string; zeile: number | null; spalte: string | null; wert?: string; meldung: string };
|
||||
|
||||
type Antwort = {
|
||||
ok: boolean;
|
||||
geprueft: boolean;
|
||||
blaetter: string[];
|
||||
fehler: Befund[];
|
||||
hinweise: Befund[];
|
||||
anzahl: Record<string, number>;
|
||||
bericht?: Record<string, number>;
|
||||
meldung?: string;
|
||||
};
|
||||
|
||||
/** Mehr als das zeigt niemand durch; der Rest steht in der Anzahl. */
|
||||
const MAX_ANZEIGE = 200;
|
||||
|
||||
export function ImportWorkbench() {
|
||||
const [dateien, setDateien] = useState<File[]>([]);
|
||||
const [antwort, setAntwort] = useState<Antwort | null>(null);
|
||||
const [laeuft, setLaeuft] = useState<"pruefen" | "uebernehmen" | null>(null);
|
||||
const [fehlschlag, setFehlschlag] = useState<string | null>(null);
|
||||
const eingabe = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function senden(nurPruefen: boolean) {
|
||||
if (dateien.length === 0) return;
|
||||
setLaeuft(nurPruefen ? "pruefen" : "uebernehmen");
|
||||
setFehlschlag(null);
|
||||
try {
|
||||
const daten = new FormData();
|
||||
for (const d of dateien) daten.append("datei", d);
|
||||
if (nurPruefen) daten.append("pruefen", "1");
|
||||
const antwort = await fetch("/api/import", { method: "POST", body: daten });
|
||||
const inhalt = (await antwort.json()) as Antwort;
|
||||
setAntwort(inhalt);
|
||||
} catch {
|
||||
setFehlschlag("Die Datei konnte nicht übertragen werden. Bitte erneut versuchen.");
|
||||
} finally {
|
||||
setLaeuft(null);
|
||||
}
|
||||
}
|
||||
|
||||
function neueAuswahl(liste: FileList | null) {
|
||||
setDateien(liste ? Array.from(liste) : []);
|
||||
// Ein alter Bericht zu einer neuen Datei ist schlimmer als keiner.
|
||||
setAntwort(null);
|
||||
setFehlschlag(null);
|
||||
}
|
||||
|
||||
const uebernommen = antwort?.bericht !== undefined;
|
||||
const bereit = antwort?.ok === true && antwort.geprueft && !uebernommen;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className={`${CARD_CLASS} p-5`}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-ink">Datei wählen</h2>
|
||||
<p className="mt-1 max-w-prose text-sm text-ink-muted">
|
||||
Eine Excel-Mappe mit den Blättern <strong>Standorte</strong>, <strong>Organisation</strong>,{" "}
|
||||
<strong>Jobkatalog</strong>, <strong>Planstellen</strong>, <strong>Personen</strong>,{" "}
|
||||
<strong>Historie</strong> und <strong>Angehörige</strong> — oder je Blatt eine CSV-Datei, deren Name dem
|
||||
Blatt entspricht. Nicht jedes Blatt muss dabei sein.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/api/import/template"
|
||||
className="shrink-0 rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
Vorlage herunterladen
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref={eingabe}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".xlsx,.xlsm,.csv"
|
||||
onChange={(e) => neueAuswahl(e.target.files)}
|
||||
className="block w-full max-w-md text-sm text-ink-muted file:mr-3 file:rounded-md file:border file:border-border
|
||||
file:bg-surface file:px-3 file:py-2 file:text-sm file:font-semibold file:text-ink hover:file:bg-brand-50"
|
||||
/>
|
||||
<Button onClick={() => senden(true)} disabled={dateien.length === 0 || laeuft !== null}>
|
||||
{laeuft === "pruefen" ? "Wird geprüft…" : "Prüfen"}
|
||||
</Button>
|
||||
</div>
|
||||
{dateien.length > 0 && (
|
||||
<p className="mt-2 text-xs text-ink-muted">
|
||||
{dateien.length === 1 ? dateien[0].name : `${dateien.length} Dateien`} ausgewählt
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{fehlschlag && (
|
||||
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
|
||||
{fehlschlag}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{antwort?.meldung && (
|
||||
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
|
||||
{antwort.meldung}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{antwort && !antwort.meldung && (
|
||||
<section className={`${CARD_CLASS} p-5`}>
|
||||
<h2 className="text-sm font-bold text-ink">{uebernommen ? "Übernommen" : "Ergebnis der Prüfung"}</h2>
|
||||
|
||||
<table className="mt-3 w-full max-w-md text-sm">
|
||||
<tbody>
|
||||
{Object.entries(uebernommen ? antwort.bericht! : antwort.anzahl)
|
||||
.filter(([, n]) => n > 0)
|
||||
.map(([blatt, n]) => (
|
||||
<tr key={blatt} className="border-b border-border-subtle last:border-0">
|
||||
<td className="py-1.5 text-ink-body">{blatt}</td>
|
||||
<td className="py-1.5 text-right font-semibold tabular-nums text-ink">{n}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{uebernommen ? (
|
||||
<p className="mt-4 text-sm text-ink-muted">
|
||||
Die Daten stehen jetzt in der Anwendung. Der Vorgang ist im Audit-Log vermerkt.
|
||||
</p>
|
||||
) : antwort.ok ? (
|
||||
<>
|
||||
<p className="mt-4 text-sm text-ink-body">
|
||||
Keine Beanstandung. Beim Übernehmen entsteht genau das oben Gezeigte — alles in einem Zug oder gar
|
||||
nichts.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button onClick={() => senden(false)} disabled={laeuft !== null || !bereit}>
|
||||
{laeuft === "uebernehmen" ? "Wird übernommen…" : "Übernehmen"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-4 text-sm font-semibold text-danger-text">
|
||||
{antwort.fehler.length} Beanstandung{antwort.fehler.length === 1 ? "" : "en"} — es wurde nichts
|
||||
geschrieben.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{antwort && antwort.fehler.length > 0 && (
|
||||
<BefundListe titel="Zu beheben" befunde={antwort.fehler} art="fehler" />
|
||||
)}
|
||||
{antwort && antwort.hinweise.length > 0 && (
|
||||
<BefundListe titel="Hinweise" befunde={antwort.hinweise} art="hinweis" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BefundListe({ titel, befunde, art }: { titel: string; befunde: Befund[]; art: "fehler" | "hinweis" }) {
|
||||
const sichtbar = befunde.slice(0, MAX_ANZEIGE);
|
||||
return (
|
||||
<section className={`overflow-x-auto ${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,22 +2,17 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { AsOfPicker } from "./AsOfPicker";
|
||||
import { EmployeeTree } from "./EmployeeTree";
|
||||
import { PositionTree } from "./PositionTree";
|
||||
import { ReorgWorkbench } from "./ReorgWorkbench";
|
||||
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam, ReorgScenarioSummary } from "./types";
|
||||
import type { OrgEmployee, OrgUnitNode, OrgVacancy } from "./types";
|
||||
|
||||
type View = "ma" | "pos" | "reo";
|
||||
type View = "ma" | "pos";
|
||||
|
||||
type OrgChartClientProps = {
|
||||
employees: OrgEmployee[];
|
||||
divisions: OrgDivision[];
|
||||
departments: OrgDepartment[];
|
||||
teams: OrgTeam[];
|
||||
openPositions: OpenPositionResolved[];
|
||||
reorgScenarios: ReorgScenarioSummary[];
|
||||
units: OrgUnitNode[];
|
||||
vacancies: OrgVacancy[];
|
||||
asOf: string;
|
||||
today: string;
|
||||
projectedCount: number;
|
||||
@@ -28,11 +23,8 @@ type OrgChartClientProps = {
|
||||
|
||||
export function OrgChartClient({
|
||||
employees,
|
||||
divisions,
|
||||
departments,
|
||||
teams,
|
||||
openPositions,
|
||||
reorgScenarios,
|
||||
units,
|
||||
vacancies,
|
||||
asOf,
|
||||
today,
|
||||
projectedCount,
|
||||
@@ -40,7 +32,6 @@ export function OrgChartClient({
|
||||
focusId,
|
||||
}: OrgChartClientProps) {
|
||||
const [view, setView] = useState<View>("ma");
|
||||
const isToday = asOf === today;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -49,35 +40,14 @@ export function OrgChartClient({
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ value: "ma", label: "Mitarbeiter" },
|
||||
{ value: "pos", label: "Positionen" },
|
||||
{ value: "reo", label: "Reorganisation" },
|
||||
{ value: "pos", label: "Organisation" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{view !== "reo" && (
|
||||
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
|
||||
)}
|
||||
|
||||
{view === "ma" && <EmployeeTree employees={employees} focusId={focusId} />}
|
||||
{view === "pos" && (
|
||||
<PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} />
|
||||
)}
|
||||
{view === "reo" &&
|
||||
(isToday ? (
|
||||
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
|
||||
) : (
|
||||
// A reorg planned against a past or projected roster would be
|
||||
// applied to the *live* org anyway — better to send the user back
|
||||
// to today than to let them assemble moves from a roster that is
|
||||
// not the one the change would hit.
|
||||
<div className="rounded border border-border bg-white p-6 text-sm text-ink-body">
|
||||
<p className="font-semibold text-ink">Reorganisation nur zum heutigen Stand</p>
|
||||
<p className="mt-1 text-ink-muted">
|
||||
Es ist ein abweichender Stichtag gewählt. Reorganisationen wirken immer auf die aktuelle Struktur — wechseln
|
||||
Sie zurück auf „Heute“, um eine zu planen.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{view === "pos" && <PositionTree employees={employees} units={units} vacancies={vacancies} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,56 +2,34 @@
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
|
||||
import type { ChartNode, OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
|
||||
import type { ChartNode, OrgEmployee, OrgUnitNode, OrgVacancy } 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";
|
||||
|
||||
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 = {
|
||||
employees: OrgEmployee[];
|
||||
divisions: OrgDivision[];
|
||||
departments: OrgDepartment[];
|
||||
teams: OrgTeam[];
|
||||
openPositions: OpenPositionResolved[];
|
||||
units: OrgUnitNode[];
|
||||
vacancies: OrgVacancy[];
|
||||
};
|
||||
|
||||
export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set(["root"]));
|
||||
export function PositionTree({ employees, units, vacancies }: PositionTreeProps) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(units.filter((u) => u.parent_id === null).map((u) => `unit-${u.id}`)));
|
||||
const [mode, setMode] = useState<ViewMode>("list");
|
||||
|
||||
// useCallback-stable: GraphOrgChart's layout memo depends on these
|
||||
// references, so unstable functions would force a Dagre re-layout on
|
||||
// every unrelated re-render.
|
||||
// useCallback-stabil: das Layout-Memo von GraphOrgChart hängt an diesen
|
||||
// Referenzen, instabile Funktionen erzwängen sonst bei jedem Re-Render ein
|
||||
// neues Dagre-Layout.
|
||||
const toggle = useCallback((id: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -60,216 +38,178 @@ export function PositionTree({ employees, divisions, departments, teams, openPos
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
|
||||
|
||||
const ceo = employees.find((e) => e.org_level === 0) ?? null;
|
||||
const { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle } = useMemo(() => {
|
||||
const divisionHeadByDivision = new Map<string, OrgEmployee>();
|
||||
const teamLeadByTeam = new Map<string, OrgEmployee>();
|
||||
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
|
||||
for (const e of employees) {
|
||||
if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
|
||||
if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
|
||||
if (!e.is_lead && e.org_level === 3 && e.team_id) {
|
||||
if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
|
||||
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 };
|
||||
}, [employees]);
|
||||
const openByTeam = useMemo(() => {
|
||||
const map = new Map<string, OpenPositionResolved[]>();
|
||||
for (const p of openPositions) {
|
||||
if (!map.has(p.team_id)) map.set(p.team_id, []);
|
||||
map.get(p.team_id)!.push(p);
|
||||
}
|
||||
return map;
|
||||
}, [openPositions]);
|
||||
|
||||
// Mirrors the JSX walk below into the generic ChartNode shape for graph
|
||||
// mode — same synthetic ids ("root", div-*, dept-*, team-*, teamKey-title)
|
||||
// the list already uses as `expanded` keys, so one Set drives both.
|
||||
const chartTree = useMemo<ChartNode[]>(() => {
|
||||
if (!ceo) return [];
|
||||
|
||||
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) ?? [];
|
||||
|
||||
const titleGroups: ChartNode[] = Array.from(icsByTitle.entries()).map(([title, people]) => ({
|
||||
id: `${teamKey}-${title}`,
|
||||
kind: "group",
|
||||
label: title,
|
||||
sublabel: `${people.length}x besetzt`,
|
||||
children: people.map((p) => ({
|
||||
id: p.id,
|
||||
kind: "person",
|
||||
label: `${p.first_name} ${p.last_name}`,
|
||||
href: `/employees/${p.id}`,
|
||||
avatar: { firstName: p.first_name, lastName: p.last_name },
|
||||
children: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
const vacancyNodes: ChartNode[] = openForTeam.map((p) => ({
|
||||
id: `vac-${p.id}`,
|
||||
kind: "vacancy",
|
||||
label: `${p.position_number} · ${p.title}`,
|
||||
href: "/positions",
|
||||
children: [],
|
||||
}));
|
||||
|
||||
return {
|
||||
id: teamKey,
|
||||
kind: "role",
|
||||
label: `Teamleitung ${team.name}`,
|
||||
sublabel: lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant",
|
||||
vacant: !lead,
|
||||
children: [...titleGroups, ...vacancyNodes],
|
||||
};
|
||||
}
|
||||
|
||||
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]);
|
||||
const tree = useMemo(() => buildUnitTree(units, employees, vacancies), [units, employees, vacancies]);
|
||||
|
||||
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" }]} />
|
||||
<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} />
|
||||
<LazyGraphOrgChart tree={tree} 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>
|
||||
{tree.map((node) => (
|
||||
<ListNode key={node.id} node={node} depth={0} expanded={expanded} onToggle={toggle} />
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (e.is_chief) chiefOf.set(e.org_unit_id, e);
|
||||
else {
|
||||
const list = staffOf.get(e.org_unit_id) ?? [];
|
||||
list.push(e);
|
||||
staffOf.set(e.org_unit_id, list);
|
||||
}
|
||||
}
|
||||
|
||||
const vacantOf = new Map<string, OrgVacancy[]>();
|
||||
for (const v of vacancies) {
|
||||
const list = vacantOf.get(v.org_unit_id) ?? [];
|
||||
list.push(v);
|
||||
vacantOf.set(v.org_unit_id, list);
|
||||
}
|
||||
|
||||
function build(unit: OrgUnitNode): ChartNode {
|
||||
const key = `unit-${unit.id}`;
|
||||
const chief = chiefOf.get(unit.id);
|
||||
|
||||
const subUnits = (childUnits.get(unit.id) ?? []).map(build);
|
||||
|
||||
// Nach Tätigkeit gruppiert: dreissig Zeilen „Maschinenbediener:in" sagen
|
||||
// weniger als eine Zeile „Maschinenbediener:in — 30x besetzt".
|
||||
const byTitle = new Map<string, OrgEmployee[]>();
|
||||
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,
|
||||
sublabel: `${people.length}x besetzt`,
|
||||
children: people
|
||||
.slice()
|
||||
.sort((a, b) => a.last_name.localeCompare(b.last_name, "de"))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
kind: "person" as const,
|
||||
label: `${p.first_name} ${p.last_name}`,
|
||||
href: `/employees/${p.id}`,
|
||||
avatar: { firstName: p.first_name, lastName: p.last_name },
|
||||
absent: p.absent,
|
||||
badge: p.absent ? (p.absence_type ?? "Abwesend") : undefined,
|
||||
children: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
const vacancyNodes: ChartNode[] = (vacantOf.get(unit.id) ?? [])
|
||||
.filter((v) => !v.is_chief) // die Leitungsvakanz steht schon in der Kopfzeile
|
||||
.sort((a, b) => a.position_number.localeCompare(b.position_number))
|
||||
.map((v) => ({
|
||||
id: `vac-${v.position_id}`,
|
||||
kind: "vacancy" as const,
|
||||
label: `+ ${v.position_number} · ${v.job_title}`,
|
||||
href: "/positions",
|
||||
vacant: true,
|
||||
children: [],
|
||||
}));
|
||||
|
||||
return {
|
||||
id: key,
|
||||
kind: "role",
|
||||
label: `${unit.org_number} · ${unit.name}`,
|
||||
sublabel: chief
|
||||
? `Leitung: ${chief.first_name} ${chief.last_name}${chief.absent ? " (abwesend)" : ""}`
|
||||
: "Leitung vakant",
|
||||
vacant: !chief,
|
||||
children: [...subUnits, ...titleGroups, ...vacancyNodes],
|
||||
};
|
||||
}
|
||||
|
||||
return (childUnits.get(null) ?? []).map(build);
|
||||
}
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
"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,16 +15,30 @@ export type OrgEmployee = {
|
||||
/** This person is on a long-term absence as of the chart's date. */
|
||||
absent: boolean;
|
||||
absence_type: string | null;
|
||||
team_id: string | null;
|
||||
division_id: string;
|
||||
is_lead: boolean;
|
||||
org_level: number;
|
||||
/** Die Einheit der Planstelle — die einzige Verortung, die es noch gibt. */
|
||||
org_unit_id: string;
|
||||
/** Diese Planstelle führt ihre Einheit (SAP-OM: A012). */
|
||||
is_chief: boolean;
|
||||
position_id: string;
|
||||
position_number: string;
|
||||
};
|
||||
|
||||
export type OrgDivision = { id: string; org_number: string; name: string };
|
||||
export type OrgDepartment = { id: string; org_number: string; name: string; division_id: string };
|
||||
export type OrgTeam = { id: string; org_number: string; name: string; department_id: string };
|
||||
export type ReorgScenarioSummary = { id: string; name: string; effective_date: string; applied: boolean; applied_at: string | null };
|
||||
/** Eine Planstelle, die am Stichtag niemand innehat. */
|
||||
export type OrgVacancy = {
|
||||
position_id: string;
|
||||
position_number: string;
|
||||
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
|
||||
// into for the graphical (React Flow + Dagre) view — see GraphOrgChart.
|
||||
|
||||
@@ -2,51 +2,64 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { createPosition, searchSuperiors, type SuperiorSearchResult } from "@/actions/positions";
|
||||
import { createPosition } from "@/actions/positions";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Field, SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Lookup } from "@/components/ui/Lookup";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
export type UnitOption = { id: string; name: string; unit_type: string; depth: number; hasChief: boolean };
|
||||
|
||||
export function CreatePositionModal({ open, onClose, teams }: { open: boolean; onClose: () => void; teams: Team[] }) {
|
||||
// Eine Planstelle gehört zu genau einer Organisationseinheit — mehr braucht
|
||||
// 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 router = useRouter();
|
||||
const [title, setTitle] = useState("");
|
||||
const [isLead, setIsLead] = useState(false);
|
||||
const [superior, setSuperior] = useState<SuperiorSearchResult | null>(null);
|
||||
const [teamId, setTeamId] = useState("");
|
||||
const [jobTitle, setJobTitle] = useState("");
|
||||
const [orgUnitId, setOrgUnitId] = useState("");
|
||||
const [isChief, setIsChief] = useState(false);
|
||||
const [validFrom, setValidFrom] = useState(todayIso);
|
||||
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() {
|
||||
setTitle("");
|
||||
setIsLead(false);
|
||||
setSuperior(null);
|
||||
setTeamId("");
|
||||
setJobTitle("");
|
||||
setOrgUnitId("");
|
||||
setIsChief(false);
|
||||
setValidFrom(todayIso());
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title || !superior || !validFrom || (isLead && !teamId)) {
|
||||
if (!jobTitle || !orgUnitId || !validFrom) {
|
||||
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await createPosition({
|
||||
title,
|
||||
superior_employee_id: superior.id,
|
||||
is_lead: isLead,
|
||||
team_id: isLead ? teamId : undefined,
|
||||
org_unit_id: orgUnitId,
|
||||
job_title: jobTitle,
|
||||
is_chief: isChief && !chiefTaken,
|
||||
valid_from: validFrom,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast("Position ausgeschrieben.");
|
||||
showToast("Planstelle angelegt.");
|
||||
router.refresh();
|
||||
onClose();
|
||||
reset();
|
||||
@@ -59,77 +72,46 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Position ausschreiben"
|
||||
title="Planstelle anlegen"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
Ausschreiben
|
||||
Anlegen
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<TextField label="Titel" required value={title} onChange={setTitle} />
|
||||
<label className="flex items-center gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isLead}
|
||||
onChange={(e) => {
|
||||
setIsLead(e.target.checked);
|
||||
setSuperior(null);
|
||||
}}
|
||||
/>
|
||||
Führungsposition (Teamleitung)
|
||||
</label>
|
||||
{!superior ? (
|
||||
<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"
|
||||
label="Organisationseinheit"
|
||||
required
|
||||
value={teamId}
|
||||
onChange={setTeamId}
|
||||
value={orgUnitId}
|
||||
onChange={(v) => {
|
||||
setOrgUnitId(v);
|
||||
setIsChief(false);
|
||||
}}
|
||||
placeholder="Bitte wählen…"
|
||||
options={teams.map((t) => ({ value: t.id, label: t.name }))}
|
||||
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">
|
||||
<input type="checkbox" checked={isChief} disabled={!unit || chiefTaken} onChange={(e) => setIsChief(e.target.checked)} />
|
||||
Leitungsplanstelle für diese Einheit
|
||||
</label>
|
||||
{chiefTaken && (
|
||||
<p className="-mt-2 text-xs text-ink-muted">Für {unit?.name} besteht bereits eine Leitungsplanstelle.</p>
|
||||
)}
|
||||
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
|
||||
</div>
|
||||
|
||||
138
components/positions/EditPositionModal.tsx
Normal file
138
components/positions/EditPositionModal.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { updatePosition } from "@/actions/positions";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { SelectField, TextField } from "@/components/ui/Field";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { UnitOption } from "./CreatePositionModal";
|
||||
|
||||
// Ändern statt löschen und neu anlegen.
|
||||
//
|
||||
// Die Planstellennummer steht in Ausschreibungen, Budgets und
|
||||
// Protokolleinträgen. Wer wegen eines Tippfehlers in der Tätigkeit eine neue
|
||||
// Nummer vergibt, macht die alten Bezüge wertlos — deshalb gibt es diesen
|
||||
// Dialog.
|
||||
|
||||
/**
|
||||
* Erwartet eine Planstelle, keine „vielleicht keine".
|
||||
*
|
||||
* Die aufrufende Seite hängt einen `key` mit der Kennung daran und rendert
|
||||
* ihn nur, solange etwas bearbeitet wird. Dadurch baut React den Dialog je
|
||||
* Planstelle neu auf, und die Felder lassen sich direkt aus den Eigenschaften
|
||||
* vorbelegen — statt sie in einem Effekt nachzuziehen, der beim zweiten
|
||||
* Öffnen kurz die Werte der vorigen Stelle zeigt.
|
||||
*/
|
||||
export function EditPositionModal({
|
||||
position,
|
||||
units,
|
||||
onClose,
|
||||
}: {
|
||||
position: OpenPositionResolved;
|
||||
units: UnitOption[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [jobTitle, setJobTitle] = useState(position.title);
|
||||
const [orgUnitId, setOrgUnitId] = useState(position.org_unit_id);
|
||||
const [isChief, setIsChief] = useState(position.is_chief);
|
||||
const [validFrom, setValidFrom] = useState(position.valid_from);
|
||||
const [validTo, setValidTo] = useState(position.valid_to ?? "");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const unit = units.find((u) => u.id === orgUnitId);
|
||||
// Die eigene Leitung zählt nicht als Hindernis für sich selbst.
|
||||
const chiefTaken = Boolean(unit?.hasChief) && !(position.is_chief && orgUnitId === position.org_unit_id);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!jobTitle.trim() || !orgUnitId || !validFrom) {
|
||||
showToast("Tätigkeit, Einheit und Gültigkeitsbeginn sind Pflicht.", "error");
|
||||
return;
|
||||
}
|
||||
setPending(true);
|
||||
const result = await updatePosition({
|
||||
position_id: position.id,
|
||||
org_unit_id: orgUnitId,
|
||||
job_title: jobTitle.trim(),
|
||||
is_chief: isChief && !chiefTaken,
|
||||
valid_from: validFrom,
|
||||
valid_to: validTo || null,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
showToast("Planstelle geändert.");
|
||||
router.refresh();
|
||||
onClose();
|
||||
} else {
|
||||
// Die Meldungen der Datenbankfunktion sind für die Oberfläche
|
||||
// geschrieben („Diese Planstelle ist vergeben …“) und werden gezeigt.
|
||||
showToast(result.error ?? "Die Änderung war nicht möglich.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Planstelle ${position.position_number}`}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} pending={pending}>
|
||||
Speichern
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<TextField label="Tätigkeit" required value={jobTitle} onChange={setJobTitle} />
|
||||
|
||||
<SelectField
|
||||
label="Organisationseinheit"
|
||||
required
|
||||
value={orgUnitId}
|
||||
onChange={setOrgUnitId}
|
||||
placeholder="Bitte wählen…"
|
||||
options={units.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${" ".repeat(u.depth)}${u.name} (${u.unit_type})`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<TextField label="Gültig ab" required type="date" value={validFrom} onChange={setValidFrom} />
|
||||
<TextField label="Gültig bis" type="date" value={validTo} onChange={setValidTo} />
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm text-ink-body">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5 h-4 w-4 rounded border-border text-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
checked={isChief && !chiefTaken}
|
||||
disabled={chiefTaken}
|
||||
onChange={(e) => setIsChief(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
Leitungsplanstelle
|
||||
{chiefTaken && (
|
||||
<span className="block text-xs text-ink-muted">
|
||||
Für {unit?.name} besteht bereits eine Leitungsplanstelle.
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p className="rounded-md bg-surface px-3 py-2 text-xs leading-relaxed text-ink-muted">
|
||||
Die Planstellennummer bleibt. Ist die Stelle vergeben, lassen sich Einheit und Gültigkeitsende nicht ändern —
|
||||
ein Wechsel der Einheit ist eine Versetzung und gehört zur Person, nicht zur Stelle.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -6,79 +6,123 @@ import { useState } from "react";
|
||||
import { deletePosition } from "@/actions/positions";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { CreatePositionModal } from "./CreatePositionModal";
|
||||
import { CreatePositionModal, type UnitOption } from "./CreatePositionModal";
|
||||
import { EditPositionModal } from "./EditPositionModal";
|
||||
|
||||
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
|
||||
|
||||
type PositionsPageClientProps = {
|
||||
openPositions: OpenPositionWithDays[];
|
||||
teams: { id: string; org_number: string; name: string; department_id: string }[];
|
||||
units: UnitOption[];
|
||||
};
|
||||
|
||||
export function PositionsPageClient({ openPositions, teams }: PositionsPageClientProps) {
|
||||
export function PositionsPageClient({ openPositions, units }: PositionsPageClientProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<OpenPositionWithDays | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const today = todayIso();
|
||||
|
||||
// Getrennt, weil es zwei verschiedene Aussagen sind: „hier fehlt jemand"
|
||||
// und „das entsteht erst". In einer Liste vermischt liest sich eine
|
||||
// Planstelle zum 01.10. wie eine Vakanz, um die sich niemand kümmert.
|
||||
const offen = openPositions.filter((p) => !p.future);
|
||||
const kuenftig = openPositions.filter((p) => p.future);
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeletingId(id);
|
||||
const result = await deletePosition(id);
|
||||
setDeletingId(null);
|
||||
if (result.success) {
|
||||
showToast("Position gelöscht.");
|
||||
showToast("Planstelle entfernt.");
|
||||
router.refresh();
|
||||
} else {
|
||||
showToast(result.error ?? "Fehler beim Löschen.", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function Karte({ p }: { p: OpenPositionWithDays }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-bold text-ink">Offene Positionen ({openPositions.length})</h2>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Position ausschreiben
|
||||
</Button>
|
||||
</div>
|
||||
{openPositions.length === 0 ? (
|
||||
<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">
|
||||
{openPositions.map((p) => {
|
||||
const notYetValid = p.valid_from > today;
|
||||
return (
|
||||
<div key={p.id} className="flex flex-col rounded border border-border p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-ink">{p.title}</div>
|
||||
<div className="relative flex flex-col rounded border border-border transition-colors focus-within:border-brand-500 hover:border-brand-500">
|
||||
{/* Die ganze Karte öffnet den Änderungsdialog; der Papierkorb liegt
|
||||
absolut darüber. Ein Knopf im Knopf wäre ungültiges HTML und mit
|
||||
der Tastatur nicht erreichbar. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(p)}
|
||||
aria-label={`Planstelle ${p.position_number} (${p.title}) ändern`}
|
||||
className="flex flex-col items-start rounded p-3 pr-10 text-left
|
||||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||
>
|
||||
<span className="text-sm font-semibold text-ink">{p.title}</span>
|
||||
<span className="text-xs text-ink-muted">
|
||||
{p.position_number} · {p.orgLabel}
|
||||
</span>
|
||||
<span className="mt-1 text-xs text-ink-muted">
|
||||
{p.is_chief ? "Leitungsplanstelle · " : ""}
|
||||
{p.future ? `gültig ab ${fmtDate(p.valid_from)}` : `seit ${p.daysOpen} Tagen unbesetzt`}
|
||||
</span>
|
||||
{p.valid_to && <span className="text-xs font-semibold text-warning-text">endet am {fmtDate(p.valid_to)}</span>}
|
||||
{p.managerName && <span className="text-xs text-ink-muted">berichtet an {p.managerName}</span>}
|
||||
</button>
|
||||
|
||||
<Button
|
||||
variant="icon"
|
||||
onClick={() => handleDelete(p.id)}
|
||||
pending={deletingId === p.id}
|
||||
aria-label={`Position ${p.title} löschen`}
|
||||
className="-mr-1 -mt-1 hover:!text-danger-solid"
|
||||
aria-label={`Planstelle ${p.position_number} (${p.title}) entfernen`}
|
||||
className="absolute right-2 top-2 hover:!text-danger-solid"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">
|
||||
{p.position_number} · {p.orgLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div>
|
||||
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<section className="rounded border border-border bg-white p-4">
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-bold text-ink">Unbesetzte Planstellen ({offen.length})</h2>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Planstelle anlegen
|
||||
</Button>
|
||||
</div>
|
||||
{offen.length === 0 ? (
|
||||
<p className="text-sm text-ink-muted">Derzeit ist jede geltende Planstelle besetzt oder vergeben.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{offen.map((p) => (
|
||||
<Karte key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
|
||||
{kuenftig.length > 0 && (
|
||||
<section className="rounded border border-border bg-white p-4">
|
||||
<h2 className="text-sm font-bold text-ink">Künftige Planstellen ({kuenftig.length})</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-ink-muted">
|
||||
Beschlossen, aber noch nicht gültig. Sie zählen nicht als Vakanz und lassen sich bis zum Beginn ändern.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{kuenftig.map((p) => (
|
||||
<Karte key={p.id} p={p} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} units={units} />
|
||||
{/* `key` sorgt dafür, dass React je Planstelle einen frischen Dialog
|
||||
baut — sonst blieben beim zweiten Öffnen die Werte des ersten
|
||||
stehen. */}
|
||||
{editing && (
|
||||
<EditPositionModal key={editing.id} position={editing} units={units} onClose={() => setEditing(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart3, Building2, History, LayoutGrid, Network, Users, X } from "lucide-react";
|
||||
import { BarChart3, Building2, History, LayoutGrid, Network, Upload, Users, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
@@ -12,6 +12,7 @@ const NAV_ITEMS = [
|
||||
{ href: "/positions", label: "Positionen", icon: Building2 },
|
||||
{ href: "/reports", label: "Berichte", icon: BarChart3 },
|
||||
{ href: "/audit", label: "Audit-Log", icon: History },
|
||||
{ href: "/import", label: "Import", icon: Upload },
|
||||
] as const;
|
||||
|
||||
export function isActiveRoute(pathname: string, href: string): boolean {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
services:
|
||||
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:
|
||||
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
|
||||
ports:
|
||||
- "3000:3000"
|
||||
|
||||
148
docs/azure-migration.md
Normal file
148
docs/azure-migration.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# 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?
|
||||
137
docs/entra-sso.md
Normal file
137
docs/entra-sso.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# 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,6 +13,50 @@ const eslintConfig = defineConfig([
|
||||
"build/**",
|
||||
"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;
|
||||
|
||||
139
lib/auth/config.ts
Normal file
139
lib/auth/config.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
26
lib/auth/require-hr.ts
Normal file
26
lib/auth/require-hr.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
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 };
|
||||
}
|
||||
32
lib/auth/session.ts
Normal file
32
lib/auth/session.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
}
|
||||
77
lib/db/index.ts
Normal file
77
lib/db/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
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
Normal file
102
lib/db/pool.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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;
|
||||
}
|
||||
56
lib/db/rpc.ts
Normal file
56
lib/db/rpc.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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." };
|
||||
}
|
||||
}
|
||||
49
lib/db/schema.ts
Normal file
49
lib/db/schema.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
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,3 +1,5 @@
|
||||
import type { Expression, ExpressionBuilder, SqlBool } from "kysely";
|
||||
import type { Schema } from "./db/schema";
|
||||
import type { EmploymentStatus } from "./supabase/types";
|
||||
|
||||
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
|
||||
@@ -19,58 +21,59 @@ import type { EmploymentStatus } from "./supabase/types";
|
||||
// tests/integration/employee-status-filter.test.ts asserts the two agree
|
||||
// against a real database, which is the only place that can prove it.
|
||||
|
||||
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;
|
||||
};
|
||||
type Eb = ExpressionBuilder<Schema, "employees">;
|
||||
|
||||
/** True once the person has started and has not left yet. */
|
||||
function employed<Q extends Filterable>(query: Q, asOf: string): Q {
|
||||
return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q;
|
||||
function employed(eb: Eb, asOf: string): Expression<SqlBool> {
|
||||
return eb.and([eb("entry_date", "<=", asOf), eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)])]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows a PostgREST query to the employees whose *derived* status on
|
||||
* `asOf` is one of `statuses`. Only the combinations the UI offers are
|
||||
* supported; anything else is left unfiltered rather than silently applying
|
||||
* a wrong one.
|
||||
* Die Bedingung für die Menge, deren *abgeleiteter* Status am Stichtag einer
|
||||
* der genannten ist — oder null, wenn nicht eingeschränkt werden soll.
|
||||
*
|
||||
* Nur die Kombinationen, die die Oberfläche anbietet, sind abgedeckt. Für
|
||||
* alles andere kommt null zurück: lieber nicht filtern als falsch filtern.
|
||||
*/
|
||||
export function applyDerivedStatusFilter<Q extends Filterable>(query: Q, statuses: EmploymentStatus[], asOf: string): Q {
|
||||
export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf: string): Expression<SqlBool> | null {
|
||||
const wanted = new Set(statuses);
|
||||
if (wanted.size === 0) return query;
|
||||
if (wanted.size === 0) return null;
|
||||
|
||||
// A single non-employed status is a straight date comparison.
|
||||
if (wanted.size === 1 && wanted.has("Geplant")) return query.gt("entry_date", asOf) as Q;
|
||||
if (wanted.size === 1 && wanted.has("Ausgetreten")) return query.not("exit_date", "is", null).lte("exit_date", asOf) as Q;
|
||||
if (wanted.size === 1 && wanted.has("Geplant")) return eb("entry_date", ">", asOf);
|
||||
if (wanted.size === 1 && wanted.has("Ausgetreten")) {
|
||||
return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]);
|
||||
}
|
||||
|
||||
const wantsAktiv = wanted.has("Aktiv");
|
||||
const wantsKarenz = wanted.has("Karenz");
|
||||
|
||||
if (wantsAktiv && wantsKarenz && wanted.size === 2) {
|
||||
// Everyone employed today, whether or not they are on leave.
|
||||
return employed(query, asOf);
|
||||
}
|
||||
if (wantsAktiv && wantsKarenz && wanted.size === 2) return employed(eb, asOf);
|
||||
|
||||
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
|
||||
return employed(query, asOf)
|
||||
.not("karenz_start_date", "is", null)
|
||||
.lte("karenz_start_date", asOf)
|
||||
.or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q;
|
||||
return eb.and([
|
||||
employed(eb, asOf),
|
||||
eb("karenz_start_date", "is not", null),
|
||||
eb("karenz_start_date", "<=", asOf),
|
||||
eb.or([eb("karenz_return_date", "is", null), eb("karenz_return_date", ">", asOf)]),
|
||||
]);
|
||||
}
|
||||
|
||||
if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
|
||||
// Employed but *not* inside a karenz window: either no start date, a
|
||||
// start still ahead, or a return that has already happened.
|
||||
return employed(query, asOf).or(
|
||||
`karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}`
|
||||
) as Q;
|
||||
return eb.and([
|
||||
employed(eb, asOf),
|
||||
eb.or([
|
||||
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
|
||||
// path today; filtering on a guess would be worse than not filtering.
|
||||
return query;
|
||||
return null;
|
||||
}
|
||||
|
||||
335
lib/import/load.ts
Normal file
335
lib/import/load.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
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;
|
||||
}
|
||||
242
lib/import/parse.ts
Normal file
242
lib/import/parse.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
// 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.`],
|
||||
};
|
||||
}
|
||||
449
lib/import/schema.ts
Normal file
449
lib/import/schema.ts
Normal file
@@ -0,0 +1,449 @@
|
||||
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);
|
||||
}
|
||||
405
lib/import/validate.ts
Normal file
405
lib/import/validate.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
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 };
|
||||
}
|
||||
125
lib/import/werte.ts
Normal file
125
lib/import/werte.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
// 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,26 +1,32 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { fetchAllRows } from "./supabase/query";
|
||||
import type { Tx } from "./db";
|
||||
import type { Database } from "./supabase/types";
|
||||
|
||||
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
|
||||
employeeName: string;
|
||||
};
|
||||
|
||||
// "Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
|
||||
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon wer
|
||||
// sie verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). Zwei
|
||||
// einfache Queries, in JS gemerged — gleiches Muster wie loadEventHistory
|
||||
// in lib/reports-data.ts, da der handgeschriebene Database-Typ keine
|
||||
// relationalen Embeddings für eine einzelne verschachtelte Query kennt.
|
||||
export async function loadOpenNotes(supabase: SupabaseClient<Database>): Promise<OpenNote[]> {
|
||||
const [{ data: notes }, employees] = await Promise.all([
|
||||
supabase.from("employee_notes").select("*").eq("done", false).order("created_at", { ascending: false }),
|
||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
|
||||
]);
|
||||
// „Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
|
||||
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie
|
||||
// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt).
|
||||
//
|
||||
// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
|
||||
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
|
||||
// direkten Zugang ist es schlicht ein Join.
|
||||
export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
|
||||
const rows = await tx
|
||||
.selectFrom("employee_notes as n")
|
||||
.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();
|
||||
|
||||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||||
return (notes ?? []).map((n) => {
|
||||
const emp = employeeById.get(n.employee_id);
|
||||
return { ...n, employeeName: emp ? `${emp.first_name} ${emp.last_name}` : "Unbekannt" };
|
||||
return rows.map((row) => {
|
||||
const { first_name, last_name, ...note } = row;
|
||||
return {
|
||||
...(note as Database["public"]["Tables"]["employee_notes"]["Row"]),
|
||||
employeeName: first_name && last_name ? `${first_name} ${last_name}` : "Unbekannt",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
85
lib/om-reporting.ts
Normal file
85
lib/om-reporting.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// 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,48 +1,123 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Tx } from "./db";
|
||||
import type { Database } from "./supabase/types";
|
||||
|
||||
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher
|
||||
// aus divisions/departments/teams zusammengesteckt wurde, ergibt sich jetzt
|
||||
// aus org_units.parent_id — und damit funktioniert es auch für eine fünfte
|
||||
// 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"];
|
||||
|
||||
export type OrgMaps = {
|
||||
divisions: Map<string, Division>;
|
||||
departments: Map<string, Department>;
|
||||
teams: Map<string, Team>;
|
||||
units: Map<string, OrgUnit>;
|
||||
/** Tiefensuche ab der Wurzel: eine Einheit steht immer hinter ihrem Elternteil. */
|
||||
unitList: OrgUnit[];
|
||||
/** Abstand zur Wurzel; die Wurzel selbst hat 0. */
|
||||
depthOf: Map<string, number>;
|
||||
childrenOf: Map<string | null, OrgUnit[]>;
|
||||
locations: Map<string, Location>;
|
||||
divisionList: Division[];
|
||||
locationList: Location[];
|
||||
};
|
||||
|
||||
// Org reference data is tiny (9 divisions / 16 departments / 35 teams / 5
|
||||
// locations) — fetched whole and joined client-side rather than per-row.
|
||||
export async function loadOrgMaps(supabase: SupabaseClient<Database>): Promise<OrgMaps> {
|
||||
const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
supabase.from("departments").select("*"),
|
||||
supabase.from("teams").select("*"),
|
||||
supabase.from("locations").select("*").order("name"),
|
||||
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
|
||||
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
|
||||
export async function loadOrgMaps(tx: Tx): Promise<OrgMaps> {
|
||||
const [units, locations] = await Promise.all([
|
||||
tx
|
||||
.selectFrom("org_units")
|
||||
.select(["id", "org_number", "name", "parent_id", "unit_type"])
|
||||
.orderBy("org_number")
|
||||
.execute(),
|
||||
tx.selectFrom("locations").selectAll().orderBy("name").execute(),
|
||||
]);
|
||||
|
||||
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 {
|
||||
divisions: new Map((divisions ?? []).map((d) => [d.id, d])),
|
||||
departments: new Map((departments ?? []).map((d) => [d.id, d])),
|
||||
teams: new Map((teams ?? []).map((t) => [t.id, t])),
|
||||
locations: new Map((locations ?? []).map((l) => [l.id, l])),
|
||||
divisionList: divisions ?? [],
|
||||
locationList: locations ?? [],
|
||||
units: new Map(units.map((u) => [u.id, u])),
|
||||
unitList,
|
||||
depthOf,
|
||||
childrenOf,
|
||||
locations: new Map(locations.map((l) => [l.id, l])),
|
||||
locationList: locations,
|
||||
};
|
||||
}
|
||||
|
||||
export function breadcrumbFor(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null) {
|
||||
const division = divisionId ? orgMaps.divisions.get(divisionId) : undefined;
|
||||
const team = teamId ? orgMaps.teams.get(teamId) : undefined;
|
||||
const department = team ? orgMaps.departments.get(team.department_id) : undefined;
|
||||
return { division, department, team };
|
||||
/** Wurzel zuerst, die Einheit selbst zuletzt. */
|
||||
export function ancestorsOf(maps: OrgMaps, unitId: string | null | undefined): OrgUnit[] {
|
||||
const chain: OrgUnit[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = unitId ? maps.units.get(unitId) : undefined;
|
||||
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;
|
||||
}
|
||||
|
||||
export function breadcrumbLabel(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null): string {
|
||||
const { division, department, team } = breadcrumbFor(orgMaps, divisionId, teamId);
|
||||
return [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–";
|
||||
/** Die Einheit und alles darunter — die Menge, die ein Filter „Bereich X" meint. */
|
||||
export function subtreeOf(maps: OrgMaps, unitId: string): string[] {
|
||||
const out: string[] = [];
|
||||
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,32 +1,33 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { OrgEmployee } from "@/components/orgchart/types";
|
||||
import { resolveActingManagers } from "./acting-manager";
|
||||
import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types";
|
||||
import type { Tx } from "./db";
|
||||
import { todayIso } from "./format";
|
||||
import { deriveStatusAsOf } from "./reports";
|
||||
import { fetchAllRows } from "./supabase/query";
|
||||
import type { Database } from "./supabase/types";
|
||||
import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting";
|
||||
|
||||
// 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:
|
||||
// Das Organigramm, wie es an einem Stichtag stand oder stehen wird.
|
||||
//
|
||||
// past/today employee_assignments — the interval covering `asOf`
|
||||
// future pending_org_changes — effective-dated moves not yet applied
|
||||
// membership entry/exit/karenz — who counted as staff on that date
|
||||
// Im Altmodell mussten dafür drei Quellen versöhnt werden, weil keine den
|
||||
// ganzen Zeitstrahl abdeckte: eine mitgeschriebene Zuordnungshistorie für die
|
||||
// Vergangenheit, vorgemerkte Änderungen für die Zukunft und die
|
||||
// Ein-/Austrittsdaten für die Frage, wer überhaupt dazuzählte.
|
||||
//
|
||||
// See supabase/migrations/*_employee_assignment_history.sql for why the
|
||||
// placement timeline is captured by a trigger rather than per-RPC.
|
||||
// Im OM-Modell fällt das zusammen. position_assignments ist zeitabhängig, also
|
||||
// beantwortet eine einzige Abfrage „wer besetzte am Stichtag welche
|
||||
// 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.
|
||||
|
||||
const ORG_COLUMNS =
|
||||
"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;
|
||||
/** Änderungsarten, die jemanden in der Organisation verschieben. */
|
||||
const PLACEMENT_CHANGES = ["transfer"] as const;
|
||||
|
||||
export type OrgAsOfResult = {
|
||||
employees: OrgEmployee[];
|
||||
/** How many placements were projected from not-yet-applied changes. */
|
||||
vacancies: OrgVacancy[];
|
||||
/** Wie viele Platzierungen aus noch nicht angewandten Änderungen stammen. */
|
||||
projectedCount: number;
|
||||
/** Earliest date the assignment history actually covers. */
|
||||
/** Frühester Tag, den die Besetzungshistorie tatsächlich abdeckt. */
|
||||
historyStartsAt: string | null;
|
||||
};
|
||||
|
||||
@@ -36,196 +37,193 @@ type EmployeeRow = {
|
||||
first_name: string;
|
||||
last_name: 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_return_date: string | null;
|
||||
absence_type: string | null;
|
||||
};
|
||||
|
||||
type AssignmentRow = {
|
||||
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;
|
||||
type PositionRow = {
|
||||
id: string;
|
||||
position_number: string;
|
||||
org_unit_id: string;
|
||||
is_chief: boolean;
|
||||
jobs: { title: string };
|
||||
};
|
||||
|
||||
type AssignmentRow = { employee_id: string; position_id: string };
|
||||
|
||||
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
|
||||
|
||||
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> {
|
||||
export async function loadOrgAsOf(tx: Tx, asOf: string): Promise<OrgAsOfResult> {
|
||||
const today = todayIso();
|
||||
|
||||
const [allEmployees, assignments, teams, departments, pending] = await Promise.all([
|
||||
fetchAllRows(() => supabase.from("employees").select(ORG_COLUMNS).order("id")),
|
||||
fetchAllRows(() =>
|
||||
supabase
|
||||
.from("employee_assignments")
|
||||
.select("employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from")
|
||||
.lte("valid_from", asOf)
|
||||
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
|
||||
.order("employee_id")
|
||||
),
|
||||
fetchAllRows(() => supabase.from("teams").select("id, department_id").order("id")),
|
||||
fetchAllRows(() => supabase.from("departments").select("id, division_id").order("id")),
|
||||
// Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise
|
||||
// Nachladen weg: sechs Abfragen, jede vollständig.
|
||||
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
|
||||
tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(),
|
||||
|
||||
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", "j.title"])
|
||||
.where("p.valid_from", "<=", asOf)
|
||||
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
|
||||
.orderBy("p.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
|
||||
? fetchAllRows(() =>
|
||||
supabase
|
||||
.from("pending_org_changes")
|
||||
.select("employee_id, change_type, effective_date, payload")
|
||||
.eq("status", "pending")
|
||||
.lte("effective_date", asOf)
|
||||
.in("change_type", [...PLACEMENT_CHANGES])
|
||||
.order("effective_date")
|
||||
)
|
||||
? tx
|
||||
.selectFrom("pending_org_changes")
|
||||
.select(["employee_id", "effective_date", "payload"])
|
||||
.where("status", "=", "pending")
|
||||
.where("effective_date", "<=", asOf)
|
||||
.where("change_type", "in", [...PLACEMENT_CHANGES])
|
||||
.orderBy("effective_date")
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
|
||||
tx.selectFrom("position_assignments").select("valid_from").orderBy("valid_from").limit(1).executeTakeFirst(),
|
||||
]);
|
||||
|
||||
return resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending });
|
||||
return resolveOrgSnapshot({
|
||||
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
|
||||
// snapshot, with no Supabase client in sight, so the reconciliation rules can
|
||||
// be tested directly.
|
||||
/**
|
||||
* Der reine Teil: aus den Zeilen den Stand machen, ohne Datenbank, damit die
|
||||
* Regeln direkt prüfbar sind.
|
||||
*/
|
||||
export function resolveOrgSnapshot({
|
||||
asOf,
|
||||
employees: allEmployees,
|
||||
units,
|
||||
positions,
|
||||
assignments,
|
||||
teams,
|
||||
departments,
|
||||
employees: allEmployees,
|
||||
pending,
|
||||
historyStartsAt,
|
||||
}: {
|
||||
asOf: string;
|
||||
employees: EmployeeRow[];
|
||||
units: OmUnit[];
|
||||
positions: PositionRow[];
|
||||
assignments: AssignmentRow[];
|
||||
teams: { id: string; department_id: string }[];
|
||||
departments: { id: string; division_id: string }[];
|
||||
employees: EmployeeRow[];
|
||||
pending: PendingRow[];
|
||||
historyStartsAt: string | null;
|
||||
}): OrgAsOfResult {
|
||||
const assignmentByEmployee = new Map(assignments.map((a) => [a.employee_id, a]));
|
||||
// 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] : [];
|
||||
})
|
||||
);
|
||||
const positionById = new Map(positions.map((p) => [p.id, p]));
|
||||
const employeeById = new Map(allEmployees.map((e) => [e.id, e]));
|
||||
|
||||
// Employed (or on leave) on that date — the same derivation the Berichte
|
||||
// page uses, so the two can never disagree on who counted when.
|
||||
const staff = allEmployees.filter((e) => {
|
||||
const status = deriveStatusAsOf(e, asOf);
|
||||
return status === "Aktiv" || status === "Karenz";
|
||||
});
|
||||
// Dieselbe Ableitung wie in deriveStatusAsOf() und in om_reporting_lines(),
|
||||
// damit die drei nie auseinanderlaufen können.
|
||||
const isAbsent = (e: EmployeeRow) =>
|
||||
e.karenz_start_date !== null &&
|
||||
e.karenz_start_date <= asOf &&
|
||||
(e.karenz_return_date === null || asOf < e.karenz_return_date);
|
||||
|
||||
const resolved = staff.map((e) => {
|
||||
const a = assignmentByEmployee.get(e.id);
|
||||
return {
|
||||
employee: e,
|
||||
managerId: a ? a.manager_id : e.manager_id,
|
||||
placement: {
|
||||
team_id: a ? a.team_id : e.team_id,
|
||||
division_id: a ? a.division_id : e.division_id,
|
||||
job_title: a ? a.job_title : e.job_title,
|
||||
is_lead: a ? a.is_lead : e.is_lead,
|
||||
org_level: a ? a.org_level : e.org_level,
|
||||
} satisfies Placement,
|
||||
};
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
// Project the future. Ordered by effective_date, so a later move wins.
|
||||
const byId = new Map(resolved.map((r) => [r.employee.id, r]));
|
||||
// Die Zukunft projizieren: nach effective_date sortiert, eine spätere
|
||||
// Versetzung gewinnt.
|
||||
const moved = new Set<string>();
|
||||
for (const change of pending) {
|
||||
const target = byId.get(change.employee_id);
|
||||
if (!target) continue;
|
||||
const payload = change.payload as { new_team_id?: string; target_team_id?: string; new_title?: string };
|
||||
const newTeamId = payload.new_team_id ?? payload.target_team_id ?? null;
|
||||
if (newTeamId) {
|
||||
target.placement.team_id = newTeamId;
|
||||
const divisionId = teamDivision.get(newTeamId);
|
||||
if (divisionId) target.placement.division_id = divisionId;
|
||||
moved.add(target.employee.id);
|
||||
}
|
||||
if (payload.new_title) target.placement.job_title = payload.new_title;
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 })));
|
||||
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)!),
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
const lines = resolveReportingLines(units, holders);
|
||||
|
||||
// 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 };
|
||||
const employees: OrgEmployee[] = lines.map((l) => {
|
||||
const e = employeeById.get(l.employeeId)!;
|
||||
const position = positionById.get(l.positionId)!;
|
||||
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,
|
||||
id: e.id,
|
||||
personnel_number: e.personnel_number,
|
||||
first_name: e.first_name,
|
||||
last_name: e.last_name,
|
||||
// Die Tätigkeit der Planstelle, nicht das Freitextfeld auf der Person:
|
||||
// bei einer projizierten Versetzung ist nur die erste schon richtig.
|
||||
job_title: position.jobs.title,
|
||||
manager_id: l.actingManagerId,
|
||||
// Nur setzen, wenn eine Vertretung im Spiel ist — sonst zeigt die
|
||||
// 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,
|
||||
};
|
||||
});
|
||||
|
||||
const historyStartsAt = assignments.reduce<string | null>(
|
||||
(min, a) => (min === null || a.valid_from < min ? a.valid_from : min),
|
||||
null
|
||||
);
|
||||
// Unbesetzte Planstellen. Im Altmodell waren offene Stellen eine eigene
|
||||
// Tabelle neben der Organisation; hier sind sie schlicht das Komplement.
|
||||
const besetzt = new Set(positionOf.values());
|
||||
const vacancies: OrgVacancy[] = positions
|
||||
.filter((p) => !besetzt.has(p.id))
|
||||
.map((p) => ({
|
||||
position_id: p.id,
|
||||
position_number: p.position_number,
|
||||
job_title: p.jobs.title,
|
||||
org_unit_id: p.org_unit_id,
|
||||
is_chief: p.is_chief,
|
||||
}));
|
||||
|
||||
return { employees, projectedCount: moved.size, historyStartsAt };
|
||||
return { employees, vacancies, projectedCount: moved.size, historyStartsAt };
|
||||
}
|
||||
|
||||
143
lib/placement.ts
Normal file
143
lib/placement.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
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]));
|
||||
}
|
||||
169
lib/positions.ts
169
lib/positions.ts
@@ -1,41 +1,150 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { breadcrumbLabel, loadOrgMaps } from "./org";
|
||||
import type { Database } from "./supabase/types";
|
||||
import type { Tx } from "./db";
|
||||
import { todayIso } from "./format";
|
||||
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org";
|
||||
|
||||
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
|
||||
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
|
||||
// zweites Objekt daneben, das mit der Organisation synchron gehalten werden
|
||||
// müsste.
|
||||
|
||||
export type OpenPositionResolved = {
|
||||
id: string;
|
||||
position_number: string;
|
||||
title: string;
|
||||
team_id: string;
|
||||
division_id: string;
|
||||
is_lead: boolean;
|
||||
reports_to_employee_id: string | null;
|
||||
org_unit_id: string;
|
||||
is_chief: boolean;
|
||||
valid_from: string;
|
||||
created_at: string;
|
||||
valid_to: string | null;
|
||||
/** Die Einheit selbst — für den Änderungsdialog, der sie vorbelegt. */
|
||||
org_unit_name: string;
|
||||
/**
|
||||
* Ob die Planstelle heute schon gilt.
|
||||
*
|
||||
* Eine, die erst zum 01.10. entsteht, ist nicht „seit 2 Tagen unbesetzt" —
|
||||
* sie ist geplant. Beides in einer Liste zu zeigen ist richtig, beides
|
||||
* gleich zu benennen wäre falsch.
|
||||
*/
|
||||
future: boolean;
|
||||
/** Wer die Stelle nach der Berichtslinie führen wird. */
|
||||
managerName: string | null;
|
||||
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.
|
||||
export async function loadOpenPositions(supabase: SupabaseClient<Database>): Promise<OpenPositionResolved[]> {
|
||||
const orgMaps = await loadOrgMaps(supabase);
|
||||
const { data: positions } = await supabase
|
||||
.from("positions")
|
||||
.select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, valid_from, created_at")
|
||||
.eq("status", "open")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
const managerIds = Array.from(
|
||||
new Set((positions ?? []).map((p) => p.reports_to_employee_id).filter((id): id is string => Boolean(id)))
|
||||
);
|
||||
const { data: managers } = managerIds.length
|
||||
? await supabase.from("employees").select("id, first_name, last_name").in("id", managerIds)
|
||||
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
||||
const managerNameById = new Map((managers ?? []).map((m) => [m.id, `${m.first_name} ${m.last_name}`]));
|
||||
|
||||
return (positions ?? []).map((p) => ({
|
||||
...p,
|
||||
managerName: p.reports_to_employee_id ? (managerNameById.get(p.reports_to_employee_id) ?? null) : null,
|
||||
orgLabel: breadcrumbLabel(orgMaps, p.division_id, p.team_id),
|
||||
}));
|
||||
/**
|
||||
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
|
||||
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe
|
||||
* Regel wie in om_reporting_lines(), nur ohne Inhaber:in, für die sie gälte.
|
||||
*/
|
||||
function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): string | null {
|
||||
if (!isChief) return orgUnitId;
|
||||
return maps.units.get(orgUnitId)?.parent_id ?? null;
|
||||
}
|
||||
|
||||
export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]> {
|
||||
const asOf = todayIso();
|
||||
|
||||
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", "p.valid_to", "j.title"])
|
||||
// Künftige Planstellen bleiben drin. Sie sind der Grund, warum diese
|
||||
// Ansicht existiert: eine Stelle, die zum Quartalswechsel entsteht,
|
||||
// muss vorher sichtbar und planbar sein. Vorher fielen sie durch das
|
||||
// Raster und tauchten am Stichtag unangekündigt auf.
|
||||
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
|
||||
.where((eb) =>
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.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 [];
|
||||
return tx
|
||||
.selectFrom("om_positions as p")
|
||||
.innerJoin("position_assignments as a", "a.position_id", "p.id")
|
||||
.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>();
|
||||
for (const e of ended) {
|
||||
const prev = lastEndByPosition.get(e.position_id);
|
||||
if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to);
|
||||
}
|
||||
const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, `${c.first_name} ${c.last_name}`]));
|
||||
|
||||
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,
|
||||
valid_to: p.valid_to,
|
||||
org_unit_name: orgMaps.units.get(p.org_unit_id)?.name ?? "",
|
||||
future: p.valid_from > asOf,
|
||||
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
|
||||
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
|
||||
vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Tx } from "./db";
|
||||
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
|
||||
import { loadPlacements } from "./placement";
|
||||
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
|
||||
import { fetchAllRows } from "./supabase/query";
|
||||
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
|
||||
import type { EmploymentType, HistoryEventType } from "./supabase/types";
|
||||
|
||||
// Shared by the Berichte page and /api/export/* so they can never drift on
|
||||
// what "the current view" means — same filters, same stichtag/event-window
|
||||
// rules.
|
||||
export type ReportFilters = {
|
||||
/** Id einer Organisationseinheit; wirkt auf die Einheit *und alles darunter*. */
|
||||
division?: string;
|
||||
location?: string;
|
||||
status?: string;
|
||||
@@ -16,69 +18,123 @@ export type ReportFilters = {
|
||||
export type SnapshotFilters = ReportFilters & { asOf?: 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;
|
||||
orgMaps: OrgMaps;
|
||||
divisions: { id: string; name: string }[];
|
||||
locations: { id: string; name: string }[];
|
||||
}> {
|
||||
const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([
|
||||
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 orgMaps = await loadOrgMaps(tx);
|
||||
const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name }));
|
||||
|
||||
const departmentNameById = new Map((departments ?? []).map((d) => [d.id, d.name]));
|
||||
return {
|
||||
lookups: {
|
||||
divisionName: new Map((divisions ?? []).map((d) => [d.id, d.name])),
|
||||
departmentNameByTeam: new Map((teams ?? []).map((t) => [t.id, departmentNameById.get(t.department_id) ?? "Unbekannt"])),
|
||||
teamName: new Map((teams ?? []).map((t) => [t.id, t.name])),
|
||||
locationName: new Map((locations ?? []).map((l) => [l.id, l.name])),
|
||||
},
|
||||
divisions: divisions ?? [],
|
||||
locations: locations ?? [],
|
||||
lookups: lookupsFromOrgMaps(orgMaps, locations),
|
||||
orgMaps,
|
||||
// Als Filter angeboten wird die oberste Ebene unter der Gesellschaft —
|
||||
// das, was im Altmodell „Bereich" hiess. Der Filter greift auf den
|
||||
// ganzen Teilbaum.
|
||||
divisions: orgMaps.unitList.filter((u) => u.unit_type === "Bereich").map((u) => ({ id: u.id, name: u.name })),
|
||||
locations,
|
||||
};
|
||||
}
|
||||
|
||||
const SNAPSHOT_EMPLOYEE_COLUMNS =
|
||||
"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";
|
||||
const SNAPSHOT_EMPLOYEE_COLUMNS = [
|
||||
"id",
|
||||
"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;
|
||||
|
||||
// employee_id -> number of employee_dependents rows. Selects only the FK
|
||||
// column (no dependent PII needed) since only per-employee counts feed the
|
||||
// has_dependents/avg_dependents report dimensions; counted client-side
|
||||
// since the Supabase JS client has no `count(*) group by employee_id`
|
||||
// shorthand. Shared by the Bestand pivot and the full employees export.
|
||||
export async function loadDependentsCounts(supabase: SupabaseClient<Database>): Promise<Map<string, number>> {
|
||||
const rows = await fetchAllRows(() => supabase.from("employee_dependents").select("employee_id").order("employee_id"));
|
||||
const counts = new Map<string, number>();
|
||||
for (const d of rows) counts.set(d.employee_id, (counts.get(d.employee_id) ?? 0) + 1);
|
||||
return counts;
|
||||
// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen —
|
||||
// für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist.
|
||||
export async function loadDependentsCounts(tx: Tx): Promise<Map<string, number>> {
|
||||
// Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle
|
||||
// Zeilen holt und sie selbst durchgeht.
|
||||
const rows = await tx
|
||||
.selectFrom("employee_dependents")
|
||||
.select(({ fn }) => ["employee_id", fn.countAll<string>().as("anzahl")])
|
||||
.groupBy("employee_id")
|
||||
.execute();
|
||||
return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)]));
|
||||
}
|
||||
|
||||
// Bestand zum Stichtag: reconstructs each employee's status as of `asOf`
|
||||
// (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[]> {
|
||||
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst.
|
||||
export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): Promise<ReportEmployee[]> {
|
||||
const asOf = filters.asOf || todayIso();
|
||||
|
||||
function snapshotQuery() {
|
||||
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id");
|
||||
if (filters.division) query = query.eq("division_id", filters.division);
|
||||
if (filters.location) query = query.eq("location_id", filters.location);
|
||||
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
||||
return query;
|
||||
let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id");
|
||||
if (filters.location) q = q.where("location_id", "=", filters.location);
|
||||
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
|
||||
return q;
|
||||
}
|
||||
|
||||
const [data, dependentsCounts] = await Promise.all([fetchAllRows(snapshotQuery), loadDependentsCounts(supabase)]);
|
||||
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
|
||||
snapshotQuery().execute(),
|
||||
loadDependentsCounts(tx),
|
||||
loadPlacements(tx, { asOf }),
|
||||
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const withDerivedStatus: ReportEmployee[] = data.map((e) => ({
|
||||
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst
|
||||
// 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,
|
||||
first_name: e.first_name,
|
||||
last_name: e.last_name,
|
||||
job_title: e.job_title,
|
||||
division_id: e.division_id,
|
||||
team_id: e.team_id,
|
||||
job_title: placement?.jobTitle ?? e.job_title,
|
||||
org_unit_id: orgUnitId,
|
||||
location_id: e.location_id,
|
||||
status: deriveStatusAsOf(e, asOf),
|
||||
employment_type: e.employment_type,
|
||||
@@ -98,52 +154,75 @@ export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>,
|
||||
is_laterale_fuehrung: e.is_laterale_fuehrung,
|
||||
is_c_level: e.is_c_level,
|
||||
dependents_count: dependentsCounts.get(e.id) ?? 0,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
const statuses = parseStatuses(filters.status);
|
||||
return withDerivedStatus.filter((e) => statuses.includes(e.status as (typeof statuses)[number]));
|
||||
}
|
||||
|
||||
// Ereignisse: employee_history has no division_id/team_id of its own, so
|
||||
// this joins in the affected employee's *current* org placement (two plain
|
||||
// queries, merged in JS — the hand-written Database type has no relational
|
||||
// embedding metadata for a single nested-select query).
|
||||
// Ereignisse: employee_history trägt selbst keine Organisationszuordnung, sie
|
||||
// kommt über die Planstelle, die die Person *am Tag des Ereignisses* innehatte.
|
||||
// Vorher war es die heutige — womit ein Austritt von vor zwei Jahren unter dem
|
||||
// Team stand, in das die Person nie versetzt worden war.
|
||||
//
|
||||
// from/to: "" (unset) falls back to the current calendar year; the literal
|
||||
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
|
||||
// unbounded (e.g. "alle Ereignisse bis heute", no start date).
|
||||
export async function loadEventHistory(supabase: SupabaseClient<Database>, filters: EventFilters): Promise<ReportEvent[]> {
|
||||
export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise<ReportEvent[]> {
|
||||
const currentYear = new Date().getFullYear();
|
||||
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`;
|
||||
|
||||
function historyQuery() {
|
||||
let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id");
|
||||
if (from) query = query.gte("event_date", from);
|
||||
if (to) query = query.lte("event_date", to);
|
||||
if (filters.eventType) query = query.eq("event_type", filters.eventType);
|
||||
return query;
|
||||
let q = tx
|
||||
.selectFrom("employee_history")
|
||||
.select(["employee_id", "event_date", "event_type", "description"])
|
||||
.orderBy("id");
|
||||
if (from) q = q.where("event_date", ">=", from);
|
||||
if (to) q = q.where("event_date", "<=", to);
|
||||
if (filters.eventType) q = q.where("event_type", "=", filters.eventType);
|
||||
return q;
|
||||
}
|
||||
|
||||
const [history, employees] = await Promise.all([
|
||||
fetchAllRows(historyQuery),
|
||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id").order("id")),
|
||||
const [history, employees, assignments, orgMaps] = await Promise.all([
|
||||
historyQuery().execute(),
|
||||
tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(),
|
||||
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 events: ReportEvent[] = [];
|
||||
for (const h of history) {
|
||||
const emp = employeeById.get(h.employee_id);
|
||||
if (!emp) continue;
|
||||
if (filters.division && emp.division_id !== filters.division) 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({
|
||||
employee_id: emp.id,
|
||||
first_name: emp.first_name,
|
||||
last_name: emp.last_name,
|
||||
job_title: emp.job_title,
|
||||
division_id: emp.division_id,
|
||||
team_id: emp.team_id,
|
||||
org_unit_id: unitId,
|
||||
location_id: emp.location_id,
|
||||
event_date: h.event_date,
|
||||
event_type: h.event_type,
|
||||
|
||||
@@ -81,8 +81,8 @@ export type ReportEmployee = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
job_title: string;
|
||||
division_id: string;
|
||||
team_id: string | null;
|
||||
/** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */
|
||||
org_unit_id: string | null;
|
||||
location_id: string;
|
||||
status: string;
|
||||
employment_type: string;
|
||||
@@ -104,20 +104,26 @@ export type ReportEmployee = {
|
||||
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 = {
|
||||
divisionName: Map<string, string>;
|
||||
departmentNameByTeam: Map<string, string>;
|
||||
departmentName: Map<string, string>;
|
||||
teamName: Map<string, string>;
|
||||
locationName: Map<string, string>;
|
||||
};
|
||||
|
||||
// Reconstructs status as of any date from the columns that actually carry a
|
||||
// timeline (entry/exit/Karenz), rather than trusting `employees.status`,
|
||||
// which only ever reflects *today*. Division/team/location still reflect the
|
||||
// employee's *current* assignment — the schema has no history of org-unit
|
||||
// changes over time, only free-text employee_history descriptions — so a
|
||||
// stichtag report groups by today's org placement, not the placement as of
|
||||
// that date. Documented in the UI rather than silently wrong.
|
||||
// which only ever reflects *today*.
|
||||
//
|
||||
// Die Einordnung in die Organisation wird zum selben Stichtag aufgelöst: seit
|
||||
// dem OM-Modell ist position_assignments zeitabhängig, eine Auswertung
|
||||
// gruppiert also nach der Einheit von damals. Vorher gab es diese Historie
|
||||
// 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(
|
||||
e: { entry_date: string; exit_date: string | null; karenz_start_date: string | null; karenz_return_date: string | null },
|
||||
asOf: string
|
||||
@@ -137,11 +143,11 @@ function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: strin
|
||||
export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string {
|
||||
switch (dim) {
|
||||
case "division":
|
||||
return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
|
||||
return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–";
|
||||
case "department":
|
||||
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–";
|
||||
return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–";
|
||||
case "team":
|
||||
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–";
|
||||
return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–";
|
||||
case "location":
|
||||
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
|
||||
case "status":
|
||||
@@ -256,7 +262,7 @@ export function aggregateReport(
|
||||
id: e.id,
|
||||
name: `${e.first_name} ${e.last_name}`,
|
||||
title: e.job_title,
|
||||
team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–",
|
||||
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–",
|
||||
entry_date: e.entry_date,
|
||||
}));
|
||||
const row: ReportRow = { key, value, count: rowsForGroup.length, people };
|
||||
@@ -345,8 +351,8 @@ export type ReportEvent = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
job_title: string;
|
||||
division_id: string;
|
||||
team_id: string | null;
|
||||
/** Die Einheit der Planstelle; null, wenn zum Stichtag keine besetzt war. */
|
||||
org_unit_id: string | null;
|
||||
location_id: string;
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
@@ -358,11 +364,11 @@ function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: Org
|
||||
case "event_type":
|
||||
return EVENT_TYPE_LABELS[e.event_type] ?? e.event_type;
|
||||
case "division":
|
||||
return lookups.divisionName.get(e.division_id) ?? "Unbekannt";
|
||||
return e.org_unit_id ? (lookups.divisionName.get(e.org_unit_id) ?? "Unbekannt") : "–";
|
||||
case "department":
|
||||
return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–";
|
||||
return e.org_unit_id ? (lookups.departmentName.get(e.org_unit_id) ?? "–") : "–";
|
||||
case "team":
|
||||
return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–";
|
||||
return e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–";
|
||||
case "location":
|
||||
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
|
||||
case "event_year":
|
||||
@@ -394,7 +400,7 @@ export function aggregateEvents(
|
||||
id: e.employee_id,
|
||||
name: `${e.first_name} ${e.last_name}`,
|
||||
title: e.description,
|
||||
team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–",
|
||||
team: e.org_unit_id ? (lookups.teamName.get(e.org_unit_id) ?? "–") : "–",
|
||||
entry_date: e.event_date,
|
||||
}));
|
||||
const row: ReportRow = { key, value: rowsForGroup.length, count: rowsForGroup.length, people };
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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,12 +12,24 @@ export type GenderType = "m" | "w";
|
||||
export type WorkerType = "Angestellte:r" | "Arbeiter:in";
|
||||
export type CollectiveAgreement = "Handel" | "Süßwaren";
|
||||
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 NoteCategory = "Allgemein" | "Vertraulich" | "Personalgespräch" | "Wiedervorlage" | "Lob / Anerkennung";
|
||||
// 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
|
||||
// technically required, is a type-level addition, not a rewrite.
|
||||
export type ProfileRole = "hr";
|
||||
/** Etikett einer Organisationseinheit; die Struktur steckt in parent_id. */
|
||||
export type OrgUnitType = "Gesellschaft" | "Bereich" | "Abteilung" | "Team";
|
||||
export type HistoryEventType =
|
||||
| "Eintritt"
|
||||
| "Beförderung"
|
||||
@@ -30,8 +42,6 @@ export type HistoryEventType =
|
||||
| "Reorganisation"
|
||||
| "Gehaltsanpassung"
|
||||
| "Rückkehr";
|
||||
export type PositionStatus = "open" | "filled";
|
||||
export type ReorgMoveKind = "emp" | "team" | "abt" | "dept";
|
||||
export type PendingChangeType =
|
||||
| "transfer"
|
||||
| "promotion"
|
||||
@@ -49,21 +59,6 @@ type NoRelationships = { Relationships: [] };
|
||||
export type Database = {
|
||||
public: {
|
||||
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 & {
|
||||
Row: { id: string; name: string; country: string };
|
||||
Insert: { id?: string; name: string; country: string };
|
||||
@@ -117,13 +112,8 @@ export type Database = {
|
||||
address_country: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
team_id: string | null;
|
||||
division_id: string;
|
||||
job_title: string;
|
||||
location_id: string;
|
||||
manager_id: string | null;
|
||||
org_level: number;
|
||||
is_lead: boolean;
|
||||
employment_type: EmploymentType;
|
||||
weekly_hours: number;
|
||||
/** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */
|
||||
@@ -166,13 +156,8 @@ export type Database = {
|
||||
address_country?: string | null;
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
team_id?: string | null;
|
||||
division_id?: string;
|
||||
job_title: string;
|
||||
location_id: string;
|
||||
manager_id?: string | null;
|
||||
org_level?: number;
|
||||
is_lead?: boolean;
|
||||
employment_type?: EmploymentType;
|
||||
weekly_hours?: number;
|
||||
contract_type?: ContractType;
|
||||
@@ -208,7 +193,6 @@ export type Database = {
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
description: string;
|
||||
reorg_scenario_id: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
@@ -217,7 +201,6 @@ export type Database = {
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
description: string;
|
||||
reorg_scenario_id?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
|
||||
@@ -274,37 +257,6 @@ export type Database = {
|
||||
};
|
||||
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 & {
|
||||
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 };
|
||||
@@ -325,6 +277,13 @@ export type Database = {
|
||||
target_label: string;
|
||||
target_employee_id: 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: {
|
||||
id?: string;
|
||||
@@ -335,37 +294,10 @@ export type Database = {
|
||||
target_label: string;
|
||||
target_employee_id?: string | null;
|
||||
details?: string | null;
|
||||
changes?: AuditChange[] | null;
|
||||
};
|
||||
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 & {
|
||||
Row: {
|
||||
id: string;
|
||||
@@ -373,7 +305,6 @@ export type Database = {
|
||||
change_type: PendingChangeType;
|
||||
effective_date: string;
|
||||
payload: Record<string, unknown>;
|
||||
reorg_scenario_id: string | null;
|
||||
status: PendingChangeStatus;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
@@ -385,7 +316,6 @@ export type Database = {
|
||||
change_type: PendingChangeType;
|
||||
effective_date: string;
|
||||
payload: Record<string, unknown>;
|
||||
reorg_scenario_id?: string | null;
|
||||
status?: PendingChangeStatus;
|
||||
created_by?: string | null;
|
||||
created_at?: string;
|
||||
@@ -393,24 +323,120 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
|
||||
};
|
||||
// Written exclusively by trg_track_employee_assignment; RLS grants HR
|
||||
// read access only, hence no Insert/Update shapes worth modelling.
|
||||
employee_assignments: NoRelationships & {
|
||||
// ── SAP-OM-Modell ──────────────────────────────────────────
|
||||
// O: rekursiv über parent_id, unit_type ist nur ein Etikett.
|
||||
org_units: {
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "org_units_parent_id_fkey";
|
||||
columns: ["parent_id"];
|
||||
referencedRelation: "org_units";
|
||||
referencedColumns: ["id"];
|
||||
},
|
||||
];
|
||||
Row: {
|
||||
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;
|
||||
org_number: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
unit_type: OrgUnitType;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: never;
|
||||
Update: never;
|
||||
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;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
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>;
|
||||
@@ -428,13 +454,24 @@ export type Database = {
|
||||
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
// Planstelle anlegen bzw. schliessen — im OM-Modell Operationen auf
|
||||
// om_positions, nicht mehr auf einer eigenen Ausschreibungstabelle.
|
||||
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
update_position: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
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 };
|
||||
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 };
|
||||
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,25 +2,28 @@ import type { NextConfig } from "next";
|
||||
|
||||
// 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
|
||||
// files from next/font, and the Supabase project from
|
||||
// NEXT_PUBLIC_SUPABASE_URL — but an unenforced policy that logs violations is
|
||||
// worth more than a guessed one that blanks the app for every HR user.
|
||||
// Promote it to `Content-Security-Policy` once the reports come back clean.
|
||||
// files from next/font, and nothing else — but an unenforced policy that logs
|
||||
// violations is worth more than a guessed one that blanks the app for every
|
||||
// HR user. Promote it to `Content-Security-Policy` once the reports come back
|
||||
// clean.
|
||||
//
|
||||
// 'unsafe-inline' on script-src is not laziness: Next.js inlines its
|
||||
// bootstrap and hydration payload as inline <script> tags, so a nonce-based
|
||||
// policy means threading a per-request nonce through proxy.ts — a separate
|
||||
// change, and the reason this starts in report-only.
|
||||
function contentSecurityPolicy(): string {
|
||||
let supabaseOrigin = "";
|
||||
// Die Anmeldung schickt ein Formular an /api/auth/signin/…, und von dort
|
||||
// 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 {
|
||||
supabaseOrigin = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL ?? "").origin;
|
||||
issuerOrigin = new URL(process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER ?? "").origin;
|
||||
} catch {
|
||||
supabaseOrigin = "";
|
||||
issuerOrigin = "";
|
||||
}
|
||||
// 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(" ");
|
||||
const formAction = ["'self'", issuerOrigin].filter(Boolean).join(" ");
|
||||
|
||||
return [
|
||||
"default-src 'self'",
|
||||
@@ -28,10 +31,13 @@ function contentSecurityPolicy(): string {
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob:",
|
||||
"font-src 'self'",
|
||||
`connect-src ${connect}`,
|
||||
// Die Anwendung spricht im Browser mit niemandem ausser sich selbst: die
|
||||
// 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'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
`form-action ${formAction}`,
|
||||
"object-src 'none'",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
268
package-lock.json
generated
268
package-lock.json
generated
@@ -13,8 +13,11 @@
|
||||
"@supabase/supabase-js": "^2.110.8",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"kysely": "^0.29.4",
|
||||
"lucide-react": "^1.26.0",
|
||||
"next": "^16.2.11",
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
"pg": "^8.22.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"server-only": "^0.0.1"
|
||||
@@ -25,6 +28,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
@@ -36,6 +40,9 @@
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22 <25"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -109,6 +116,35 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -1768,6 +1804,15 @@
|
||||
"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": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
@@ -2831,6 +2876,18 @@
|
||||
"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": {
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
@@ -6892,7 +6949,6 @@
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
@@ -7113,6 +7169,15 @@
|
||||
"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": {
|
||||
"version": "0.3.23",
|
||||
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
|
||||
@@ -7868,6 +7933,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz",
|
||||
@@ -7906,6 +7998,15 @@
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -8195,6 +8296,95 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -8252,6 +8442,64 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -8958,6 +9206,15 @@
|
||||
"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": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
@@ -10185,6 +10442,15 @@
|
||||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -24,8 +24,11 @@
|
||||
"@supabase/supabase-js": "^2.110.8",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"kysely": "^0.29.4",
|
||||
"lucide-react": "^1.26.0",
|
||||
"next": "^16.2.11",
|
||||
"next-auth": "^5.0.0-beta.32",
|
||||
"pg": "^8.22.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"server-only": "^0.0.1"
|
||||
@@ -36,6 +39,7 @@
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
|
||||
111
proxy.ts
111
proxy.ts
@@ -1,68 +1,81 @@
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import NextAuth from "next-auth";
|
||||
import { NextResponse } from "next/server";
|
||||
import { authConfig } from "@/lib/auth/config";
|
||||
|
||||
// Next.js 16 renamed Middleware to Proxy (same mechanism, new filename/export).
|
||||
// This is the app's single entry-point gate (spec
|
||||
// §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,
|
||||
// explicitly-provisioned HR user are sent to /login too, with an error
|
||||
// message, instead of being let through. Previously this only checked for
|
||||
// a Supabase Auth session at all, which meant any signed-in user (even one
|
||||
// with no profile row, or the old "manager" role) could open the app.
|
||||
// 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
|
||||
// proxy does.
|
||||
export async function proxy(request: NextRequest) {
|
||||
let response = NextResponse.next({ request });
|
||||
// Next.js 16 renamed Middleware to Proxy (same mechanism, new filename and
|
||||
// export). Das hier ist die vorderste Tür: wer keine Sitzung hat, landet auf
|
||||
// /login statt in der Anwendung.
|
||||
//
|
||||
// Gebaut wird dafür eine *zweite*, absichtlich unvollständige Auth.js-Instanz
|
||||
// — nur aus lib/auth/config.ts, ohne die Rückrufe aus auth.ts. Der Grund ist
|
||||
// handfest: auth.ts spricht mit der Datenbank, und der Proxy läuft je nach
|
||||
// Betriebsart in einer Umgebung ohne Node-Module. Die Instanz hier liest das
|
||||
// Sitzungscookie und sonst nichts.
|
||||
//
|
||||
// Deshalb prüft der Proxy auch nur, *ob* jemand angemeldet ist — nicht mehr,
|
||||
// ob die Person HR-Rechte hat. Das ist keine Lücke, sondern eine Verschiebung
|
||||
// an die Stellen, die es wahrheitsgemäss beantworten können:
|
||||
//
|
||||
// • 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 supabase = createServerClient(
|
||||
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));
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
const gate = auth((request) => {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
// Auth.js' eigene Endpunkte müssen durch, bevor es eine Sitzung gibt —
|
||||
// dort entsteht sie ja erst. Ohne diese Ausnahme leitet der Proxy den
|
||||
// Rückweg aus Entra nach /login um und die Anmeldung kommt nie zustande.
|
||||
if (pathname.startsWith("/api/auth")) return NextResponse.next();
|
||||
|
||||
const isLoginRoute = request.nextUrl.pathname.startsWith("/login");
|
||||
const isLoginRoute = pathname.startsWith("/login");
|
||||
const isSignedIn = Boolean(request.auth?.user?.id);
|
||||
|
||||
if (!user) {
|
||||
if (isLoginRoute) return response;
|
||||
if (!isSignedIn) {
|
||||
if (isLoginRoute) return NextResponse.next();
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
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");
|
||||
url.search = "";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
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();
|
||||
url.pathname = "/";
|
||||
url.search = "";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
return response;
|
||||
return NextResponse.next();
|
||||
});
|
||||
|
||||
// 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 = {
|
||||
|
||||
149
supabase/build-org.ts
Normal file
149
supabase/build-org.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// 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 };
|
||||
}
|
||||
123
supabase/migrations/20260727120000_sap_om_org_model.sql
Normal file
123
supabase/migrations/20260727120000_sap_om_org_model.sql
Normal file
@@ -0,0 +1,123 @@
|
||||
-- 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;
|
||||
101
supabase/migrations/20260727120100_om_reporting_lines.sql
Normal file
101
supabase/migrations/20260727120100_om_reporting_lines.sql
Normal file
@@ -0,0 +1,101 @@
|
||||
-- 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;
|
||||
539
supabase/migrations/20260727120200_om_cutover.sql
Normal file
539
supabase/migrations/20260727120200_om_cutover.sql
Normal file
@@ -0,0 +1,539 @@
|
||||
-- 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;
|
||||
150
supabase/migrations/20260727130000_om_cleanup_and_positions.sql
Normal file
150
supabase/migrations/20260727130000_om_cleanup_and_positions.sql
Normal file
@@ -0,0 +1,150 @@
|
||||
-- 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;
|
||||
@@ -0,0 +1,77 @@
|
||||
-- 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;
|
||||
$$;
|
||||
@@ -0,0 +1,72 @@
|
||||
-- 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.
|
||||
@@ -0,0 +1,191 @@
|
||||
-- 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;
|
||||
$$;
|
||||
117
supabase/migrations/20260731090000_app_upsert_user.sql
Normal file
117
supabase/migrations/20260731090000_app_upsert_user.sql
Normal file
@@ -0,0 +1,117 @@
|
||||
-- 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;
|
||||
$$;
|
||||
42
supabase/migrations/20260803120000_import_sequence_grant.sql
Normal file
42
supabase/migrations/20260803120000_import_sequence_grant.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- 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;
|
||||
$$;
|
||||
193
supabase/migrations/20260803140000_audit_changes_detail.sql
Normal file
193
supabase/migrations/20260803140000_audit_changes_detail.sql
Normal file
@@ -0,0 +1,193 @@
|
||||
-- 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;
|
||||
$$;
|
||||
84
supabase/migrations/20260805100000_fix_stale_type_casts.sql
Normal file
84
supabase/migrations/20260805100000_fix_stale_type_casts.sql
Normal file
@@ -0,0 +1,84 @@
|
||||
-- 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;
|
||||
$$;
|
||||
@@ -0,0 +1,113 @@
|
||||
-- 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;
|
||||
$$;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- 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;
|
||||
$$;
|
||||
132
supabase/migrations/20260805130000_update_position.sql
Normal file
132
supabase/migrations/20260805130000_update_position.sql
Normal file
@@ -0,0 +1,132 @@
|
||||
-- Eine Planstelle ändern.
|
||||
--
|
||||
-- Bisher liess sie sich nur anlegen und löschen. Ein Tippfehler in der
|
||||
-- Tätigkeit oder ein falsches Gültigkeitsdatum bedeutete: löschen und neu —
|
||||
-- mit neuer Planstellennummer. Die Nummer steht aber in Stellenausschreibungen
|
||||
-- und Budgets, und die Protokollspur reisst ab.
|
||||
--
|
||||
-- Was hier bewusst **nicht** geht, steht als Sperre drin, nicht als
|
||||
-- Anmerkung — siehe die drei Prüfungen unten.
|
||||
create or replace function update_position(payload jsonb)
|
||||
returns void
|
||||
language plpgsql
|
||||
set search_path = public, pg_temp
|
||||
as $function$
|
||||
declare
|
||||
v_id uuid := (payload->>'position_id')::uuid;
|
||||
v_alt om_positions%rowtype;
|
||||
v_alt_titel text;
|
||||
v_alt_einheit text;
|
||||
v_org_unit_id uuid;
|
||||
v_job_title text := nullif(trim(payload->>'job_title'), '');
|
||||
v_is_chief boolean;
|
||||
v_valid_from date;
|
||||
v_valid_to date;
|
||||
v_job_id uuid;
|
||||
v_unit_name text;
|
||||
v_besetzt boolean;
|
||||
v_changes jsonb := '[]'::jsonb;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
select * into v_alt from om_positions where id = v_id;
|
||||
if v_alt.id is null then
|
||||
raise exception 'Die Planstelle existiert nicht.';
|
||||
end if;
|
||||
|
||||
select title into v_alt_titel from jobs where id = v_alt.job_id;
|
||||
select name into v_alt_einheit from org_units where id = v_alt.org_unit_id;
|
||||
|
||||
-- Fehlende Schlüssel heissen „unverändert", nicht „leeren".
|
||||
v_org_unit_id := coalesce(nullif(payload->>'org_unit_id','')::uuid, v_alt.org_unit_id);
|
||||
v_job_title := coalesce(v_job_title, v_alt_titel);
|
||||
v_is_chief := coalesce((payload->>'is_chief')::boolean, v_alt.is_chief);
|
||||
v_valid_from := coalesce(nullif(payload->>'valid_from','')::date, v_alt.valid_from);
|
||||
v_valid_to := case when payload ? 'valid_to' then nullif(payload->>'valid_to','')::date else v_alt.valid_to end;
|
||||
|
||||
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_valid_to is not null and v_valid_to < v_valid_from then
|
||||
raise exception 'Das Ende der Gültigkeit liegt vor ihrem Beginn.';
|
||||
end if;
|
||||
|
||||
select exists (
|
||||
select 1 from position_assignments
|
||||
where position_id = v_id and (valid_to is null or valid_to > current_date)
|
||||
) into v_besetzt;
|
||||
|
||||
-- (1) Die Einheit einer vergebenen Planstelle zu wechseln wäre eine
|
||||
-- Versetzung — mit allem, was dazugehört: Historie, Berichtslinie,
|
||||
-- Protokoll. Das gehört in transfer_employee und nicht hierher, sonst
|
||||
-- wandert jemand lautlos in eine andere Abteilung.
|
||||
if v_besetzt and v_org_unit_id is distinct from v_alt.org_unit_id then
|
||||
raise exception 'Diese Planstelle ist vergeben. Für einen Wechsel der Einheit die Versetzung benutzen.';
|
||||
end if;
|
||||
|
||||
-- (2) Ein Ende, während noch jemand darauf sitzt, hinterlässt eine
|
||||
-- Besetzung ohne Planstelle.
|
||||
if v_besetzt and v_valid_to is not null then
|
||||
raise exception 'Diese Planstelle ist vergeben und kann kein Ende der Gültigkeit bekommen.';
|
||||
end if;
|
||||
|
||||
-- (3) Je Einheit nur eine gültige Leitung. Der Unique-Index fängt das auch,
|
||||
-- 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 and id <> v_id
|
||||
) then
|
||||
raise exception 'Für % besteht bereits eine Leitungsplanstelle.', v_unit_name;
|
||||
end if;
|
||||
|
||||
-- Gleiche Tätigkeit, ein Katalogeintrag — dieselbe Regel wie beim Anlegen.
|
||||
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;
|
||||
|
||||
v_changes := app_aenderung(v_changes, 'Tätigkeit', v_alt_titel, v_job_title);
|
||||
v_changes := app_aenderung(v_changes, 'Organisationseinheit', v_alt_einheit, v_unit_name);
|
||||
v_changes := app_aenderung(v_changes, 'Leitungsplanstelle', v_alt.is_chief::text, v_is_chief::text);
|
||||
v_changes := app_aenderung(v_changes, 'Gültig ab', v_alt.valid_from::text, v_valid_from::text);
|
||||
v_changes := app_aenderung(v_changes, 'Gültig bis', v_alt.valid_to::text, v_valid_to::text);
|
||||
|
||||
-- Nichts geändert ist ein Ergebnis, kein Erfolg.
|
||||
--
|
||||
-- Vorher kehrte die Funktion hier stumm zurück, und die Oberfläche meldete
|
||||
-- „Planstelle geändert." Wer etwas eingetragen hatte, das unterwegs
|
||||
-- verworfen wurde — etwa das Leitungshäkchen, das bei bereits vergebener
|
||||
-- Leitung nicht durchkommt —, sah eine Erfolgsmeldung und eine unveränderte
|
||||
-- Liste. Das ist genau die Rückmeldung, die einen suchen lässt.
|
||||
if jsonb_array_length(v_changes) = 0 then
|
||||
raise exception 'Es wurde nichts geändert.';
|
||||
end if;
|
||||
|
||||
update om_positions
|
||||
set org_unit_id = v_org_unit_id,
|
||||
job_id = v_job_id,
|
||||
is_chief = v_is_chief,
|
||||
valid_from = v_valid_from,
|
||||
valid_to = v_valid_to
|
||||
where id = v_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details, changes)
|
||||
values (app_current_user_id(), current_actor_name(), 'Planstelle geändert',
|
||||
v_job_title || ' (' || v_unit_name || ')',
|
||||
app_aenderungsfelder(v_changes), v_changes);
|
||||
end;
|
||||
$function$;
|
||||
|
||||
do $$
|
||||
declare r text;
|
||||
begin
|
||||
foreach r in array array['anon', 'authenticated', 'service_role', 'alpenwerk_app'] loop
|
||||
if exists (select 1 from pg_roles where rolname = r) then
|
||||
execute format('grant execute on function update_position(jsonb) to %I', r);
|
||||
end if;
|
||||
end loop;
|
||||
end;
|
||||
$$;
|
||||
567
supabase/seed.ts
567
supabase/seed.ts
@@ -10,8 +10,9 @@ import { createClient } from "@supabase/supabase-js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
// Explicit .ts extension: this file is run directly by Node (type-stripping,
|
||||
// ESM), where an extensionless relative import does not resolve.
|
||||
import { svnrCheckDigit } from "../lib/svnr.ts";
|
||||
import { svnrCheckDigit, svnrErrorMessage, validateSvnr } from "../lib/svnr.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 SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
@@ -49,8 +50,15 @@ function addDays(d: Date, days: number): Date {
|
||||
r.setDate(r.getDate() + days);
|
||||
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 {
|
||||
return d.toISOString().slice(0, 10);
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${m}-${day}`;
|
||||
}
|
||||
function randomDateBetween(start: Date, end: Date): Date {
|
||||
const t = start.getTime() + Math.random() * (end.getTime() - start.getTime());
|
||||
@@ -141,9 +149,8 @@ const LOCATION_WEIGHTS: readonly (readonly [(typeof LOCATIONS)[number], number])
|
||||
];
|
||||
|
||||
// ── Org structure ────────────────────────────────────────────
|
||||
type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number };
|
||||
type DeptDef = { name: string; teams: TeamDef[] };
|
||||
type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] };
|
||||
// TeamDef/DeptDef/DivisionDef kommen aus build-org.ts — dort steht auch, was
|
||||
// daraus gebaut wird.
|
||||
|
||||
const SCALE = 1.44; // brings the ~556-person base roster up to ~800
|
||||
|
||||
@@ -154,6 +161,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Fertigung",
|
||||
leadTitle: "Abteilungsleitung Fertigung",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -162,6 +170,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Instandhaltung",
|
||||
leadTitle: "Abteilungsleitung Instandhaltung",
|
||||
teams: [
|
||||
{ name: "Elektrotechnik", leadTitle: "Teamleitung Elektrotechnik", icTitles: ["Elektrotechniker:in", "Automatisierungstechniker:in"], baseSize: 24 },
|
||||
{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Industriemechaniker:in", "Schlosser:in"], baseSize: 22 },
|
||||
@@ -175,6 +184,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Logistik",
|
||||
leadTitle: "Abteilungsleitung Logistik",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -183,6 +193,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Einkauf",
|
||||
leadTitle: "Abteilungsleitung Einkauf",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -196,6 +207,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Vertrieb",
|
||||
leadTitle: "Abteilungsleitung Vertrieb",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -204,6 +216,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Marketing",
|
||||
leadTitle: "Abteilungsleitung Marketing",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -217,6 +230,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Produktentwicklung",
|
||||
leadTitle: "Abteilungsleitung Produktentwicklung",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -224,6 +238,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Verfahrenstechnik",
|
||||
leadTitle: "Abteilungsleitung Verfahrenstechnik",
|
||||
teams: [
|
||||
{ name: "Prozessoptimierung", leadTitle: "Teamleitung Prozessoptimierung", icTitles: ["Verfahrenstechniker:in", "Prozessingenieur:in"], baseSize: 14 },
|
||||
{ name: "Anlagentechnik", leadTitle: "Teamleitung Anlagentechnik", icTitles: ["Anlagentechniker:in", "Projektingenieur:in"], baseSize: 12 },
|
||||
@@ -237,6 +252,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Qualitätssicherung",
|
||||
leadTitle: "Abteilungsleitung Qualitätssicherung",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -244,6 +260,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Lebensmittelsicherheit",
|
||||
leadTitle: "Abteilungsleitung Lebensmittelsicherheit",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -257,6 +274,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Business Applications",
|
||||
leadTitle: "Abteilungsleitung Business Applications",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -264,6 +282,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Infrastruktur",
|
||||
leadTitle: "Abteilungsleitung Infrastruktur",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -277,6 +296,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "Finanzen",
|
||||
leadTitle: "Abteilungsleitung Finanzen",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -284,6 +304,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Controlling",
|
||||
leadTitle: "Abteilungsleitung Controlling",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -297,6 +318,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
departments: [
|
||||
{
|
||||
name: "HR Business Partner",
|
||||
leadTitle: "Abteilungsleitung HR Business Partner",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -304,6 +326,7 @@ const DIVISIONS: DivisionDef[] = [
|
||||
},
|
||||
{
|
||||
name: "Personaladministration",
|
||||
leadTitle: "Abteilungsleitung Personaladministration",
|
||||
teams: [
|
||||
{ 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 },
|
||||
@@ -328,13 +351,11 @@ type EmployeeRow = {
|
||||
address_country: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
team_id: string | null;
|
||||
division_id: string;
|
||||
// Die Einordnung in die Organisation steckt jetzt ausschliesslich in der
|
||||
// Planstelle (position_assignments -> om_positions -> org_units). Keine
|
||||
// division_id/team_id/manager_id mehr auf der Person.
|
||||
job_title: string;
|
||||
location_id: string;
|
||||
manager_id: string | null;
|
||||
org_level: number;
|
||||
is_lead: boolean;
|
||||
employment_type: "Vollzeit" | "Teilzeit";
|
||||
weekly_hours: number;
|
||||
contract_type: "unbefristet" | "befristet";
|
||||
@@ -403,7 +424,7 @@ function paygradeForIc(): EmployeeRow["paygrade"] {
|
||||
]);
|
||||
}
|
||||
|
||||
function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId: string | null, divisionId: string, managerId: string | null) {
|
||||
function newHireBase(jobTitle: string) {
|
||||
const gender: "m" | "w" = chance(0.48) ? "m" : "w";
|
||||
const firstName = pick(gender === "m" ? MALE_FIRST_NAMES : FEMALE_FIRST_NAMES);
|
||||
const lastName = pick(LAST_NAMES);
|
||||
@@ -423,25 +444,35 @@ function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId
|
||||
address_country: addressCountryFor(nationality),
|
||||
email: makeEmail(firstName, lastName),
|
||||
phone: `+43 664 ${randInt(1000000, 9999999)}`,
|
||||
team_id: teamId,
|
||||
division_id: divisionId,
|
||||
job_title: jobTitle,
|
||||
location_id: location.id,
|
||||
manager_id: managerId,
|
||||
org_level: orgLevel,
|
||||
is_lead: isLead,
|
||||
};
|
||||
}
|
||||
|
||||
const employees: EmployeeRow[] = [];
|
||||
const history: HistoryRow[] = [];
|
||||
const icPoolForStatusAssignment: EmployeeRow[] = [];
|
||||
|
||||
function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { paygrade: EmployeeRow["paygrade"] }): EmployeeRow {
|
||||
const age = randInt(22, 60);
|
||||
const birthDate = birthDateForAge(age);
|
||||
function finalizeEmployee(
|
||||
base: ReturnType<typeof newHireBase>,
|
||||
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);
|
||||
const entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30));
|
||||
entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30));
|
||||
}
|
||||
const birthDate = birthDateForAge(age);
|
||||
|
||||
const employmentType: "Vollzeit" | "Teilzeit" = chance(0.8) ? "Vollzeit" : "Teilzeit";
|
||||
const weeklyHours = employmentType === "Vollzeit" ? 38.5 : pick([15, 18, 20, 25, 28, 30, 32, 35]);
|
||||
@@ -487,80 +518,45 @@ function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { paygrade
|
||||
return row;
|
||||
}
|
||||
|
||||
type TeamRef = { id: string; org_number: string; name: string; department_id: string };
|
||||
type DeptRef = { id: string; org_number: string; name: string; division_id: string };
|
||||
type DivisionRef = { id: string; org_number: string; name: string };
|
||||
// ── Organisation im OM-Modell ────────────────────────────────
|
||||
// Der Baum kommt aus buildOrg(): reine Funktion, eigene Tests
|
||||
// (tests/unit/build-org.test.ts). Der Seed entscheidet hier nur noch, *wer*
|
||||
// welche Planstelle besetzt — die Struktur selbst ist nicht mehr seine Sache.
|
||||
const COMPANY_NAME = "Alpenwerk Industrie GmbH";
|
||||
|
||||
const divisionRows: DivisionRef[] = [];
|
||||
const departmentRows: DeptRef[] = [];
|
||||
const teamRows: TeamRef[] = [];
|
||||
const scaledDivisions: DivisionDef[] = DIVISIONS.map((div) => ({
|
||||
...div,
|
||||
departments: div.departments.map((dept) => ({
|
||||
...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) })),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Geschäftsführung: small division, no departments/teams — CEO and their
|
||||
// assistant sit directly under it (§5).
|
||||
const gfDivisionId = randomUUID();
|
||||
divisionRows.push({ id: gfDivisionId, org_number: "20900000", name: "Geschäftsführung" });
|
||||
const org = buildOrg(COMPANY_NAME, scaledDivisions, randomUUID);
|
||||
const unitById = new Map(org.units.map((u) => [u.id, u]));
|
||||
const jobTitleById = new Map(org.jobs.map((j) => [j.id, j.title]));
|
||||
|
||||
const ceo = finalizeEmployee(
|
||||
newHireBase("Geschäftsführer:in", 0, true, null, gfDivisionId, null),
|
||||
{ paygrade: "F" }
|
||||
);
|
||||
const gfAssistant = finalizeEmployee(
|
||||
newHireBase("Assistenz der Geschäftsführung", 3, false, null, gfDivisionId, ceo.id),
|
||||
{ paygrade: "C" }
|
||||
);
|
||||
employees.push(ceo, gfAssistant);
|
||||
type AssignmentRow = {
|
||||
position_id: string;
|
||||
employee_id: string;
|
||||
valid_from: string;
|
||||
valid_to: string | null;
|
||||
};
|
||||
const assignments: AssignmentRow[] = [];
|
||||
|
||||
let divisionCounter = 0;
|
||||
let deptCounter = 0;
|
||||
let teamCounter = 0;
|
||||
// Wer welche Planstelle besetzt, wird bewusst nicht überall besetzt: Vakanz
|
||||
// ist im OM-Modell keine eigene Tabelle mehr, sondern eine Planstelle ohne
|
||||
// laufende Besetzung. Ein paar davon braucht es, damit "offene Stellen" und
|
||||
// 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[] {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
@@ -569,24 +565,74 @@ function shuffle<T>(arr: T[]): T[] {
|
||||
}
|
||||
return a;
|
||||
}
|
||||
const shuffledIcs = shuffle(icPoolForStatusAssignment);
|
||||
let cursor = 0;
|
||||
|
||||
// ~40 Ausgetreten
|
||||
for (let i = 0; i < 40 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||
const e = shuffledIcs[cursor];
|
||||
const entryDate = new Date(e.entry_date);
|
||||
const exitDate = randomDateBetween(addDays(entryDate, 90), TODAY);
|
||||
e.status = "Ausgetreten";
|
||||
e.exit_date = isoDate(exitDate);
|
||||
e.exit_reason = pick(EXIT_REASONS);
|
||||
history.push({ employee_id: e.id, event_date: e.exit_date, event_type: "Austritt", description: `Austritt (${e.exit_reason})` });
|
||||
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";
|
||||
}
|
||||
|
||||
// ~12 Langzeitabwesenheiten, über die Arten gestreut statt alle als Karenz —
|
||||
// die Auswertung nach Art ist sonst nicht zu sehen.
|
||||
for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||
const e = shuffledIcs[cursor];
|
||||
/** 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;
|
||||
|
||||
// Eintritt in der Zukunft: die Person ist angelegt, die Planstelle heute noch
|
||||
// vakant, die Besetzung beginnt erst. Genau der Fall, für den die Planstellen
|
||||
// zeitabhängig sind.
|
||||
for (const p of geplanteStellen) {
|
||||
const futureEntry = addDays(TODAY, randInt(10, 90));
|
||||
const title = jobTitleById.get(p.job_id)!;
|
||||
const e = finalizeEmployee(newHireBase(title), { paygrade: paygradeForIc(), entryDate: futureEntry });
|
||||
e.status = "Geplant";
|
||||
employees.push(e);
|
||||
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
|
||||
// ist die Auswertung nach Art nicht zu sehen. Zwei davon treffen bewusst eine
|
||||
// Teamleitung, damit die Vertretungsregel auch mit *abwesender* (nicht nur
|
||||
// unbesetzter) Leitung Daten hat.
|
||||
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 karenzStart = randomDateBetween(addDays(entryDate, 180), addDays(TODAY, -10));
|
||||
const returnDate = addDays(TODAY, randInt(10, 300));
|
||||
@@ -603,65 +649,89 @@ for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||
});
|
||||
}
|
||||
|
||||
// ~3 Geplant (future entry). A person who hasn't started yet can't already
|
||||
// have a Beförderung or other history predating that future entry date —
|
||||
// found during the consolidation review that reassigning an already-
|
||||
// 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];
|
||||
// Geplante Austritte: noch aktiv, die Besetzung endet an einem Datum in der
|
||||
// Zukunft.
|
||||
for (let i = 0; i < GEPLANTER_AUSTRITT && cursor < statusPool.length; i++, cursor++) {
|
||||
const e = statusPool[cursor];
|
||||
const futureExit = addDays(TODAY, randInt(10, 90));
|
||||
e.exit_date = isoDate(futureExit);
|
||||
e.exit_reason = pick(EXIT_REASONS);
|
||||
const a = assignments.find((x) => x.employee_id === e.id)!;
|
||||
a.valid_to = e.exit_date;
|
||||
}
|
||||
|
||||
// ── Open positions (§4.6 / §5) ───────────────────────────────
|
||||
type PositionRow = {
|
||||
title: string;
|
||||
team_id: string;
|
||||
is_lead: boolean;
|
||||
reports_to_employee_id: string | null;
|
||||
status: "open";
|
||||
created_at: string;
|
||||
};
|
||||
const positions: PositionRow[] = [];
|
||||
// ── Ausgetretene als Vorgänger:innen auf besetzten Planstellen ───────
|
||||
// Im Altmodell hingen Ausgetretene weiter an einem Team und liessen dessen
|
||||
// Planstellen als vakant erscheinen. Im OM-Modell hat eine Planstelle eine
|
||||
// Besetzungshistorie: die vorherige Besetzung ist beendet, die heutige läuft.
|
||||
// Voraussetzung ist, dass der Austritt vor dem Eintritt der heutigen
|
||||
// Besetzung liegt — sonst wäre die Planstelle zweimal gleichzeitig besetzt.
|
||||
{
|
||||
const pool = shuffle([...teamRows]);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const team = pool[i % pool.length];
|
||||
const leadOfTeam = employees.find((e) => e.team_id === team.id && e.is_lead);
|
||||
const isLeadPosition = i < 2; // first two are leadership requisitions
|
||||
const reportsTo = isLeadPosition
|
||||
? (employees.find((e) => e.division_id === leadOfTeam?.division_id && e.org_level === 1)?.id ?? null)
|
||||
: (leadOfTeam?.id ?? null);
|
||||
positions.push({
|
||||
title: isLeadPosition ? `Teamleitung ${team.name}` : "Neue Position",
|
||||
team_id: team.id,
|
||||
is_lead: isLeadPosition,
|
||||
reports_to_employee_id: reportsTo,
|
||||
status: "open",
|
||||
created_at: new Date(addDays(TODAY, -randInt(1, 45))).toISOString(),
|
||||
const holderOf = new Map(icEmployees.map((e) => [e.id, e]));
|
||||
const uebernehmbar = shuffle(
|
||||
assignments.filter((a) => {
|
||||
const holder = holderOf.get(a.employee_id);
|
||||
// Genug Vorlauf, damit vor der heutigen Besetzung noch eine ganze
|
||||
// Beschäftigung Platz hat.
|
||||
return holder && new Date(holder.entry_date) > addDays(TODAY, -8 * 365) && holder.status === "Aktiv";
|
||||
})
|
||||
).slice(0, AUSGETRETEN);
|
||||
|
||||
for (const a of uebernehmbar) {
|
||||
const nachfolgerEintritt = new Date(a.valid_from);
|
||||
const exitDate = addDays(nachfolgerEintritt, -randInt(1, 60));
|
||||
const entryDate = addDays(exitDate, -randInt(400, 3000));
|
||||
const p = org.positions.find((x) => x.id === a.position_id)!;
|
||||
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 ───────────────────────────────────────────
|
||||
/** 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) {
|
||||
for (let i = 0; i < rows.length; i += chunkSize) {
|
||||
const chunk = rows.slice(i, i + chunkSize);
|
||||
@@ -671,34 +741,161 @@ async function insertInChunks(table: string, rows: Record<string, unknown>[], ch
|
||||
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() {
|
||||
console.log("Seeding locations...");
|
||||
pruefeInvarianten();
|
||||
|
||||
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 })));
|
||||
|
||||
console.log("Seeding divisions...");
|
||||
await insertInChunks("divisions", divisionRows);
|
||||
console.log(`Seeding ${org.units.length} org_units...`);
|
||||
// Eltern vor Kindern: der Fremdschlüssel auf parent_id wird pro Zeile
|
||||
// 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 departments...");
|
||||
await insertInChunks("departments", departmentRows);
|
||||
console.log(`Seeding ${org.jobs.length} jobs...`);
|
||||
await insertInChunks("jobs", org.jobs);
|
||||
|
||||
console.log("Seeding teams...");
|
||||
await insertInChunks("teams", teamRows);
|
||||
console.log(`Seeding ${org.positions.length} Planstellen...`);
|
||||
await insertInChunks("om_positions", org.positions);
|
||||
|
||||
console.log(`Seeding ${employees.length} employees...`);
|
||||
await insertInChunks("employees", employees);
|
||||
|
||||
console.log(`Seeding ${assignments.length} Besetzungen...`);
|
||||
await insertInChunks("position_assignments", assignments);
|
||||
|
||||
console.log(`Seeding ${history.length} employee_history rows...`);
|
||||
await insertInChunks("employee_history", history);
|
||||
|
||||
console.log(`Seeding ${positions.length} open positions...`);
|
||||
await insertInChunks("positions", positions);
|
||||
|
||||
// The app is HR-only now (see docs/decisions/0001-hr-only-access.md) — no
|
||||
// second "manager" role exists to seed a test account for. This is the
|
||||
// one deliberate, explicit bootstrap grant of HR access (not an automatic
|
||||
// one): every other new profile row defaults to is_active = false and
|
||||
// must be activated by an existing HR user (§2.3).
|
||||
console.log("Creating initial HR account...");
|
||||
// Das HR-Konto wird nicht neu angelegt: die Auth-Konten überstehen den
|
||||
// Seed, und ein zweites Konto auf dieselbe Adresse liesse sich gar nicht
|
||||
// anlegen. Fehlt es, wird es einmalig erzeugt — das ist die eine bewusste
|
||||
// Freischaltung, jede weitere profiles-Zeile startet mit is_active = false
|
||||
// und muss von HR freigeschaltet werden (§2.3).
|
||||
const { data: existing } = await supabase.from("profiles").select("id, email").eq("email", ADMIN_EMAIL).maybeSingle();
|
||||
if (existing) {
|
||||
console.log(`\nHR-Konto ${ADMIN_EMAIL} besteht weiter — Passwort unverändert.`);
|
||||
} else {
|
||||
console.log("\nLege HR-Konto an...");
|
||||
const hrPassword = randomUUID().slice(0, 12) + "!Aa1";
|
||||
const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({
|
||||
email: ADMIN_EMAIL,
|
||||
@@ -713,12 +910,40 @@ async function main() {
|
||||
role: "hr",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
console.log("\nDone.");
|
||||
console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`);
|
||||
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) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
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,7 +36,9 @@ describe("HR-only access (is_hr_user gate)", () => {
|
||||
createdUsers.push(user);
|
||||
const client = await signInAs(user);
|
||||
|
||||
const { error } = await client.from("divisions").insert({ org_number: "20999999", name: `Test-${user.id}` });
|
||||
const { error } = await client
|
||||
.from("org_units")
|
||||
.insert({ org_number: "20999999", name: `Test-${user.id}`, unit_type: "Bereich" });
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user