Org assignment history, mobile support, and a correctness pass

Data model
- employee_assignments records org placement over time (valid_from/valid_to),
  written by a trigger on `employees` rather than inside each RPC: ~70
  `update employees` statements spread over fifteen migrations mean per-call
  bookkeeping would miss paths today and again with every future RPC. A
  partial unique index enforces the one-open-interval invariant the trigger
  relies on when closing the current row.
- The Organigramm gains a Stichtag (default today). Membership comes from
  entry/exit/karenz, past placement from the new history, future placement
  projected from pending_org_changes. Placements predating the migration are
  backfilled with today's values and flagged as such in the UI, since
  employee_history only ever stored free text and cannot be reconstructed.

Correctness
- Reports and exports silently truncated at PostgREST's 1000-row cap
  (db.max_rows); employee_history is already past it at ~800 staff. Every
  whole-table read now pages explicitly.
- XLSX date cells were a day early: ExcelJS converts a Date to an Excel
  serial straight off getTime(), so a Date built at local midnight lands on
  the previous day's serial in any positive-offset zone.
- Date handling is pinned to Europe/Vienna throughout, and date-only strings
  are formatted without a Date round-trip. The dashboard's YTD window was
  built by round-tripping a local Date through toISOString(), which shifted
  it a day early and dropped 31 December entirely.
- Export routes parsed measure/group/split/eventType with unchecked `as`
  casts, so an unknown value reached column headers as `undefined` and the
  Content-Disposition filename. Parsed against the label maps now, with the
  filename slugged as a backstop.
- toXlsx keyed columns by header text, silently dropping the second of any
  two columns sharing a name — split columns take their header from data.
- The org chart tree walks had no cycle guard; nothing in the schema forbids
  a manager_id cycle, and one would hang the tab rather than misreport.
- The login page reflected ?error= verbatim, letting anyone put arbitrary
  text on the real sign-in screen; messages are looked up by code now.
- React Flow needs elementsSelectable on, or it sets pointer-events:none on
  the whole node and the expand control stops responding.

UI
- Mobile: the shell was unusable below lg — a fixed 236px margin pushed
  content off-screen with no mobile navigation at all. The sidebar is now a
  drawer, dvh replaces vh, safe-area insets are honoured, inputs are 16px so
  iOS stops zooming on focus, and form grids stack.
- Org chart nodes redesigned: per-kind accent stripes and icons, vacant
  roles called out, expand control moved to the bottom edge carrying the
  child count.
- Pagination is windowed; it previously rendered one link per page (54 for
  the employee list, unbounded for the audit log).
- Positions page reduced to open positions with a single "Besetzen" action.
- The employee Organisation tab links into the org chart focused on that
  person, reusing the chart's existing search-match highlighting.

Also included, uncommitted until now
- Dependants, HR notes, academic titles, split address fields, position
  validity and role/employment fields, with their migrations and UI.
- Docker/compose deployment setup, data-model and security-review docs.
This commit is contained in:
2026-07-24 23:38:10 +02:00
parent f96773da0f
commit 79f0e19bf8
101 changed files with 6120 additions and 700 deletions

20
.dockerignore Normal file
View File

@@ -0,0 +1,20 @@
node_modules
.next
out
build
coverage
playwright-report
test-results
blob-report
.git
.github
.vscode
.env*
!.env.example
npm-debug.log*
*.tsbuildinfo
.scratch_*
.scratch_shots
supabase/.branches
supabase/.temp
supabase/snippets

View File

@@ -1,5 +1,11 @@
# 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_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY= NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 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= SUPABASE_SERVICE_ROLE_KEY=
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it # Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it

7
.gitignore vendored
View File

@@ -46,7 +46,14 @@ next-env.d.ts
/supabase/.temp /supabase/.temp
/supabase/snippets /supabase/snippets
# per-machine (permission allowlists) and must stay out of the repo
# playwright # playwright
/playwright-report /playwright-report
/test-results /test-results
/blob-report /blob-report
# local scratch scripts/screenshots (ad-hoc verification against a real or
# seeded DB — can carry HR data or use SUPABASE_SERVICE_ROLE_KEY; never commit)
.scratch_*
/.scratch_shots/

156
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,156 @@
# Deployment mit Docker
Dieser Guide beschreibt, wie die App (bisher auf Vercel deployed, siehe
`vercel.json`) stattdessen als Docker-Container auf einem beliebigen Server
läuft.
## Was wird containerisiert und was nicht
- **Containerisiert:** nur die Next.js-App selbst (`Dockerfile`).
- **Nicht containerisiert:** Supabase (Datenbank + Auth). Die App verbindet
sich per URL/Key zu einem bestehenden Supabase-Projekt (Cloud oder
selbst gehostet) das bleibt unverändert. `supabase/` in diesem Repo ist
nur die lokale Dev-/Migrations-Umgebung (`supabase start`), kein Teil des
Deployments.
- **Ersetzt:** der Vercel-Cron-Job aus `vercel.json` (täglich 03:00 Uhr,
ruft `/api/cron/apply-pending-changes` auf, um fällige Versetzungen/
Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von
Vercel kein Vercel-Cron gibt, übernimmt das im `docker-compose.yml`
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
und demselben Bearer-Secret, das die Route bereits erwartet.
## Voraussetzungen
- Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine
enthaltene `docker compose`) auf dem Zielserver.
- Ein bestehendes Supabase-Projekt mit den Migrationen aus
`supabase/migrations/` bereits eingespielt (`supabase db push` bzw. wie
bisher).
## 1. `.env` anlegen
```bash
cp .env.example .env
```
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!) |
| `CRON_SECRET` | selbst generieren: `openssl rand -hex 32` |
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.
- `.env` steht schon in `.gitignore` nicht committen.
## 2. Bauen und lokal testen
```bash
docker compose build
docker compose up -d
docker compose logs -f app
```
App ist danach unter `http://localhost:3000` erreichbar. Healthcheck prüft
`GET /login`; Status siehe `docker compose ps`.
Cron-Sidecar prüfen:
```bash
docker compose logs -f cron
```
## 3. Auf einem Server deployen
Einfachste Variante Repo direkt auf dem Server bauen:
```bash
git clone <repo-url> && cd manner-app
cp .env.example .env # Werte eintragen
docker compose up -d --build
```
Alternative für CI/CD (Image einmal bauen, überall pullen): Image in einer
Registry (GHCR, Docker Hub, …) bauen und pushen, auf dem Server nur
`docker compose pull && docker compose up -d` ausführen. Dafür in
`docker-compose.yml` zusätzlich `image: <registry>/<name>:<tag>` setzen und
den Build in der CI-Pipeline mit den `--build-arg`-Werten für
`NEXT_PUBLIC_*` laufen lassen.
## 4. Reverse Proxy + HTTPS
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
werden ein Reverse Proxy übernimmt TLS, Rate-Limiting und Request-
Validierung. Beispiel mit [Caddy](https://caddyserver.com/) (automatisches
HTTPS via Let's Encrypt):
```caddyfile
# /etc/caddy/Caddyfile
hr.example.com {
reverse_proxy localhost:3000
}
```
`docker-compose.yml` published Port 3000 aktuell auf den Host bei
Verwendung eines Reverse Proxys auf demselben Host kann das Publishing auf
`127.0.0.1:3000:3000` eingeschränkt werden, damit der Container-Port nicht
direkt von außen erreichbar ist.
## 5. Updates ausrollen
```bash
git pull
docker compose build
docker compose up -d
```
Kurzer Downtime-Moment beim Neustart des `app`-Containers ist bei dieser
Single-Instance-Compose-Konfiguration normal. Für Zero-Downtime-Deployments
wäre eine zweite Instanz + Load Balancer nötig (siehe Abschnitt 6).
Datenbank-Migrationen (`supabase/migrations/*.sql`) werden weiterhin über
die Supabase CLI gegen das Supabase-Projekt gefahren, unabhängig vom
App-Deployment:
```bash
supabase db push
```
## 6. Hinweis bei mehreren Replicas
Läuft die App skaliert (mehrere `app`-Container hinter einem Load
Balancer), muss `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` explizit gesetzt und
auf allen Instanzen identisch sein sonst schlagen Server Actions
(`actions/*.ts`, z. B. Mitarbeiter- und Positions-Mutationen) mit "Failed to
find Server Action" fehl, wenn eine Anfrage auf einer anderen Instanz landet
als der, die das Formular gerendert hat. Erzeugen mit:
```bash
openssl rand -base64 32
```
Als zusätzliche Env-Variable in `.env` eintragen. Bei der aktuellen
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).
- **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).

48
Dockerfile Normal file
View File

@@ -0,0 +1,48 @@
# syntax=docker/dockerfile:1
# ---- deps: install dependencies (cached separately from source changes) ----
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ---- builder: compile the Next.js app ----
FROM node:22-alpine AS builder
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
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ---- runner: minimal production image ----
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
# output: "standalone" (next.config.ts) traces only the deps actually used at
# runtime, so this image doesn't carry the full node_modules tree.
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/login >/dev/null || exit 1
CMD ["node", "server.js"]

154
README.md
View File

@@ -1,36 +1,150 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # Manner HR Master
## Getting Started Interne HR-Stammdatenverwaltung: Mitarbeiter:innen, Organisationsstruktur
(Bereich/Abteilung/Team), Planstellen, Neueinstellungen, Versetzungen/
Beförderungen/Karenz, Reorganisationen und der zugehörige Audit-Trail.
First, run the development server: ## Zweck
Die App ersetzt Excel-basierte HR-Stammdatenpflege durch ein Werkzeug mit
verbindlichen Regeln (z. B. wirksame Daten statt sofortiger Änderungen,
eindeutige Positions-/Org-Nummern, verpflichtende Historie) und einem
lückenlosen Audit-Trail für jede Änderung.
**Alle Mitarbeiterdaten in diesem System sind vertraulich** — Stammdaten,
Verträge, Sozialversicherungsnummern, Angehörige und Audit-Daten. Zugriff ist
auf explizit aktivierte HR-Benutzer:innen beschränkt (siehe
[Sicherheitsprinzipien](#sicherheitsprinzipien)).
## Tech-Stack
- Next.js 16 (App Router) — **Achtung:** Next.js 16 hat Breaking Changes
gegenüber älteren Versionen (u. a. `proxy.ts` statt `middleware.ts`, siehe
`AGENTS.md`). Vor Änderungen an Framework-nahen Dateien die lokalen Docs
unter `node_modules/next/dist/docs/` konsultieren.
- React 19, TypeScript
- Supabase (Postgres, Auth, RLS) — Datenhaltung liegt vollständig in
Supabase, nicht im Next.js-Prozess.
- Tailwind CSS v4
- Vitest (Unit + Integrationstests), Playwright (E2E)
## Setup
```bash ```bash
npm install
cp .env.example .env.local # Werte eintragen, siehe unten
npm run dev npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Für lokale Supabase-Entwicklung (statt gegen ein Cloud-Projekt):
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. ```bash
supabase start # startet lokalen Postgres/Auth/Studio-Stack
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. `supabase/config.toml` und `.env.test.local` sind bereits auf die
Standard-Ports der lokalen Supabase-CLI abgestimmt.
## Learn More ## Umgebungsvariablen
To learn more about Next.js, take a look at the following resources: Siehe [`.env.example`](.env.example) für die vollständige, kommentierte
Liste. Kurzfassung:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. | Variable | Sichtbarkeit | Zweck |
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. |---|---|---|
| `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 |
| `CRON_SECRET` | Nur Server | Schützt `/api/cron/apply-pending-changes` |
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! `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).
## Deploy on Vercel ## Scripts
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. | Befehl | Zweck |
|---|---|
| `npm run dev` | Lokaler Dev-Server |
| `npm run build` | Produktions-Build |
| `npm run start` | Produktions-Server (nach `build`) |
| `npm run lint` | ESLint (`eslint-config-next`, Flat Config) |
| `npm run typecheck` | `tsc --noEmit` |
| `npm run test` | Vitest, Unit-Tests (`tests/unit/**`) |
| `npm run test:integration` | Vitest gegen eine echte (lokale) Supabase-Instanz — braucht `supabase start` und `.env.test.local` |
| `npm run test:e2e` | Playwright |
| `npm run check` | lint + typecheck + test + build in Folge |
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ## Sicherheitsprinzipien
- **RLS ist die eigentliche Schranke, nicht die UI.** Jede Tabelle hat Row
Level Security aktiv; `proxy.ts` (App-Ebene) ist Defense-in-Depth, keine
Ersatzkontrolle.
- **Ein Rollenmodell:** `profiles.role = 'hr'` + `profiles.is_active = true`,
geprüft über die SQL-Funktion `is_hr_user()`. Kein Sub-Rollensystem —
siehe [`docs/data-model.md`](docs/data-model.md#zugriffsmodell).
- **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).
- **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
[`docs/security-review.md`](docs/security-review.md).
- **Historie ist append-only** (`employee_history`, `audit_log`) — RLS
erlaubt kein `update`/`delete`. Korrekturen sind kompensierende Einträge.
## Cron-Konfiguration
`/api/cron/apply-pending-changes` wendet wirksam gewordene, zukunftsdatierte
Änderungen an (`pending_org_changes``apply_due_pending_changes()`).
- **Auf Vercel:** `vercel.json` definiert den täglichen Schedule; Vercel Cron
sendet `Authorization: Bearer <CRON_SECRET>` automatisch, wenn
`CRON_SECRET` in den Projekt-Env-Vars gesetzt ist.
- **Außerhalb von Vercel (Docker):** kein Vercel Cron verfügbar — siehe
[`DEPLOYMENT.md`](DEPLOYMENT.md) für den Cron-Sidecar-Container, der
denselben Endpoint mit demselben Schema aufruft.
- Fehlt `CRON_SECRET` oder stimmt der Header nicht, antwortet die Route mit
`401` (nicht `500` — bewusst, siehe `tests/unit/security.test.ts`).
## Supabase-Hinweise
- Schema-Quelle der Wahrheit: `supabase/migrations/`. Menschlich lesbare
Zusammenfassung: [`docs/data-model.md`](docs/data-model.md).
- Migrationen einspielen: `supabase db push` (gegen das verlinkte Projekt)
bzw. `supabase start` + automatische Anwendung für lokale Entwicklung.
- `supabase/seed.ts` und `.env.test.local` sind nur für lokale
Entwicklung/Tests gedacht, nie für ein Produktivprojekt verwenden.
## Testing
- `npm run test` — schnell, keine externen Abhängigkeiten, läuft in CI.
- `npm run test:integration` — braucht eine laufende lokale Supabase-Instanz
(`supabase start`) und `.env.test.local`; prüft RLS-Verhalten end-to-end
(siehe `tests/integration/authorization.test.ts` für das HR-Only-Zugriffs-
modell).
- `npm run test:e2e` — Playwright gegen einen laufenden Dev-/Preview-Server.
## Deployment
Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md) für Docker-basiertes Deployment
(Dockerfile, docker-compose.yml, Reverse-Proxy/TLS, Cron-Ersatz, Updates).
Für Vercel: `vercel.json` ist bereits vorhanden; Env-Vars im
Vercel-Projekt setzen (siehe oben).
## Known TODOs vor Produktivbetrieb
- **Content-Security-Policy fehlt noch** (`next.config.ts` setzt bewusst
keine CSP — Skript-/Style-/Connect-Quellen sind noch nicht vollständig
inventarisiert; ungeprüft geraten zu setzen riskiert, Hydration oder den
Supabase-Client stillschweigend zu brechen).
- **Lokale Scratch-Artefakte** (`.scratch_*`, `.scratch_shots/`) enthalten
Screenshots/Hilfsskripte aus einer früheren manuellen Verifikation und
liegen noch im Arbeitsverzeichnis. Sie sind jetzt über `.gitignore`
ausgeschlossen; vor einem Produktiv-Handover sollten sie durchgesehen und
bei Bedarf gelöscht werden.
- **Kein granulareres Rollenmodell** — aktuell HR-only (alles-oder-nichts).
Falls z. B. eine reine Lese-Rolle künftig gebraucht wird, gehört die
Erweiterung in eine neue Migration (`is_hr_user()`/RLS-Policies), nicht in
App-seitigen Code.

View File

@@ -11,7 +11,7 @@ export async function login(formData: FormData) {
const { error } = await supabase.auth.signInWithPassword({ email, password }); const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) { if (error) {
redirect(`/login?error=${encodeURIComponent("E-Mail oder Passwort ist falsch.")}`); redirect("/login?error=invalid_credentials");
} }
redirect("/"); redirect("/");

View File

@@ -3,7 +3,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { sanitizeIlikeTerm } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import type { Database } from "@/lib/supabase/types"; import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
type ActionResult = { success: boolean; error?: string }; type ActionResult = { success: boolean; error?: string };
type MutationFn = keyof Database["public"]["Functions"]; type MutationFn = keyof Database["public"]["Functions"];
@@ -19,6 +19,8 @@ async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalid
export async function hireEmployee(payload: { export async function hireEmployee(payload: {
first_name: string; first_name: string;
last_name: string; last_name: string;
title_prefix?: string[];
title_suffix?: string[];
gender: "m" | "w"; gender: "m" | "w";
birth_date: string; birth_date: string;
sv_nummer?: string; sv_nummer?: string;
@@ -34,6 +36,13 @@ export async function hireEmployee(payload: {
weekly_hours?: number; weekly_hours?: number;
paygrade?: "A" | "B" | "C" | "D" | "E" | "F"; paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
source: "Intern" | "Extern"; source: "Intern" | "Extern";
worker_type?: WorkerType;
collective_agreement?: CollectiveAgreement;
work_days?: Weekday[];
is_betriebsrat?: boolean;
has_dienstwagen?: boolean;
is_laterale_fuehrung?: boolean;
is_c_level?: boolean;
}): Promise<ActionResult & { employeeId?: string }> { }): Promise<ActionResult & { employeeId?: string }> {
const supabase = await createClient(); const supabase = await createClient();
const { data, error } = await supabase.rpc("hire_employee", { payload }); const { data, error } = await supabase.rpc("hire_employee", { payload });
@@ -102,6 +111,7 @@ export async function changeEmployeeData(payload: {
effective_date: string; effective_date: string;
person: Record<string, unknown>; person: Record<string, unknown>;
contract: Record<string, unknown>; contract: Record<string, unknown>;
role: Record<string, unknown>;
}): Promise<ActionResult> { }): Promise<ActionResult> {
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]); return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
} }
@@ -110,9 +120,42 @@ export async function rehireEmployee(payload: { employee_id: string; rehire_date
return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]); return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
} }
export async function addEmployeeDependent(payload: {
employee_id: string;
first_name: string;
last_name: string;
relationship: RelationshipType;
sv_nummer?: string;
birth_date: string;
effective_date: string;
}): Promise<ActionResult> {
return callRpc("add_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
}
export async function deleteEmployeeDependent(payload: {
dependent_id: string;
employee_id: string;
effective_date: string;
}): Promise<ActionResult> {
return callRpc("delete_employee_dependent", payload, [`/employees/${payload.employee_id}`]);
}
export async function addEmployeeNote(payload: {
employee_id: string;
category: NoteCategory;
note_text: string;
due_date?: string;
}): Promise<ActionResult> {
return callRpc("add_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]);
}
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 }; export type EmployeeSearchResult = { id: string; first_name: string; last_name: string; job_title: string; team_id: string | null };
// Shared by "Intern besetzen" (staff an open position) and the reorg // Shared by "Position besetzen" (staff an open position) and the reorg
// workbench's "Mitarbeiter:in(nen)" multi-select — both search active/ // workbench's "Mitarbeiter:in(nen)" multi-select — both search active/
// on-leave employees by name or title. // on-leave employees by name or title.
export async function searchActiveEmployees(query: string): Promise<EmployeeSearchResult[]> { export async function searchActiveEmployees(query: string): Promise<EmployeeSearchResult[]> {

View File

@@ -6,34 +6,39 @@ import { createClient } from "@/lib/supabase/server";
type ActionResult = { success: boolean; error?: string }; type ActionResult = { success: boolean; error?: string };
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
async function callRpc(
fn: "create_position" | "delete_position" | "staff_position_internally",
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 };
for (const path of revalidate) revalidatePath(path);
return { success: true };
}
export async function createPosition(payload: { export async function createPosition(payload: {
title: string; title: string;
superior_employee_id: string; superior_employee_id: string;
is_lead: boolean; is_lead: boolean;
team_id?: string; team_id?: string;
valid_from: string;
}): Promise<ActionResult> { }): Promise<ActionResult> {
const supabase = await createClient(); return callRpc("create_position", payload, POSITION_PATHS);
const { error } = await supabase.rpc("create_position", { payload }); }
if (error) return { success: false, error: error.message };
revalidatePath("/positions"); export async function deletePosition(positionId: string): Promise<ActionResult> {
revalidatePath("/orgchart"); return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
revalidatePath("/");
return { success: true };
} }
export async function staffPositionInternally(payload: { export async function staffPositionInternally(payload: {
position_id: string; position_id: string;
employee_id: string; employee_id: string;
}): Promise<ActionResult> { }): Promise<ActionResult> {
const supabase = await createClient(); return callRpc("staff_position_internally", payload, [...POSITION_PATHS, "/employees", `/employees/${payload.employee_id}`]);
const { error } = await supabase.rpc("staff_position_internally", { payload });
if (error) return { success: false, error: error.message };
revalidatePath("/positions");
revalidatePath("/orgchart");
revalidatePath("/employees");
revalidatePath(`/employees/${payload.employee_id}`);
revalidatePath("/");
return { success: true };
} }
export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string }; export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string };

View File

@@ -1,6 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { AuditFilters } from "@/components/audit/AuditFilters"; import { AuditFilters } from "@/components/audit/AuditFilters";
import { Pagination } from "@/components/ui/Pagination";
import { actionBadgeStyle } from "@/lib/colors"; import { actionBadgeStyle } from "@/lib/colors";
import { sanitizeIlikeTerm } from "@/lib/supabase/query"; import { sanitizeIlikeTerm } from "@/lib/supabase/query";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
@@ -17,11 +18,18 @@ function pageHref(params: SearchParams, page: number): string {
return `/audit?${sp.toString()}`; return `/audit?${sp.toString()}`;
} }
function fmtDateTime(iso: string): string { // Pinned to Vienna and built once: audit_log.occurred_at is a timestamptz, and
return new Intl.DateTimeFormat("de-AT", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }).format( // an unpinned formatter renders it in the *server's* zone — UTC in Docker and
new Date(iso) // on Vercel — so every entry would read an hour or two early for the people
); // the log is for.
} const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "Europe/Vienna",
});
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) { export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams; const params = await searchParams;
@@ -68,7 +76,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
{(entries ?? []).map((entry) => { {(entries ?? []).map((entry) => {
return ( return (
<tr key={entry.id} className="border-b border-border last:border-0 hover:bg-surface"> <tr key={entry.id} className="border-b border-border last:border-0 hover:bg-surface">
<td className="px-4 py-3 text-ink-body">{fmtDateTime(entry.occurred_at)}</td> <td className="px-4 py-3 text-ink-body">{dateTimeFormatter.format(new Date(entry.occurred_at))}</td>
<td className="px-4 py-3 text-ink-body">{entry.actor_name}</td> <td className="px-4 py-3 text-ink-body">{entry.actor_name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(entry.action)}`}>{entry.action}</span> <span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(entry.action)}`}>{entry.action}</span>
@@ -97,19 +105,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
</table> </table>
</div> </div>
{totalPages > 1 && ( <Pagination page={page} totalPages={totalPages} hrefFor={(p) => pageHref(params, p)} label="Audit-Log" />
<div className="flex items-center justify-center gap-2 text-sm">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<Link
key={p}
href={pageHref(params, p)}
className={`rounded px-3 py-1 ${p === page ? "bg-brand-500 text-white" : "text-ink-body hover:bg-surface"}`}
>
{p}
</Link>
))}
</div>
)}
<p className="text-xs text-ink-muted"> <p className="text-xs text-ink-muted">
Alle Änderungen an Personal-Stammdaten werden automatisch protokolliert und sind unveränderbar. Alle Änderungen an Personal-Stammdaten werden automatisch protokolliert und sind unveränderbar.

View File

@@ -11,8 +11,18 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
const { data: employee } = await supabase.from("employees").select("*").eq("id", id).single(); const { data: employee } = await supabase.from("employees").select("*").eq("id", id).single();
if (!employee) notFound(); if (!employee) notFound();
const [{ data: manager }, { data: directReports }, { data: history }, { data: divisions }, { data: departments }, { data: teams }, { data: locations }, { data: openPositions }] = const [
await Promise.all([ { data: manager },
{ data: directReports },
{ data: history },
{ data: dependents },
{ data: notes },
{ data: divisions },
{ data: departments },
{ data: teams },
{ data: locations },
{ data: openPositions },
] = await Promise.all([
employee.manager_id employee.manager_id
? supabase.from("employees").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single() ? supabase.from("employees").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single()
: Promise.resolve({ data: null }), : Promise.resolve({ data: null }),
@@ -22,6 +32,8 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
.eq("manager_id", id) .eq("manager_id", id)
.order("last_name"), .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_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("divisions").select("*").order("name"),
supabase.from("departments").select("*"), supabase.from("departments").select("*"),
supabase.from("teams").select("*"), supabase.from("teams").select("*"),
@@ -35,6 +47,8 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
manager={manager ?? null} manager={manager ?? null}
directReports={directReports ?? []} directReports={directReports ?? []}
history={history ?? []} history={history ?? []}
dependents={dependents ?? []}
notes={notes ?? []}
divisions={divisions ?? []} divisions={divisions ?? []}
departments={departments ?? []} departments={departments ?? []}
teams={teams ?? []} teams={teams ?? []}

View File

@@ -2,6 +2,7 @@ import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { EmployeeFilters } from "@/components/employees/EmployeeFilters"; import { EmployeeFilters } from "@/components/employees/EmployeeFilters";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { Pagination } from "@/components/ui/Pagination";
import { StatusChip } from "@/components/ui/StatusChip"; import { StatusChip } from "@/components/ui/StatusChip";
import { fmtDate } from "@/lib/format"; import { fmtDate } from "@/lib/format";
import { breadcrumbFor, loadOrgMaps } from "@/lib/org"; import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
@@ -126,19 +127,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
</table> </table>
</div> </div>
{totalPages > 1 && ( <Pagination page={page} totalPages={totalPages} hrefFor={(p) => pageHref(params, p)} label="Mitarbeiter:innen" />
<div className="flex items-center justify-center gap-2 text-sm">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<Link
key={p}
href={pageHref(params, p)}
className={`rounded px-3 py-1 ${p === page ? "bg-brand-500 text-white" : "text-ink-body hover:bg-surface"}`}
>
{p}
</Link>
))}
</div>
)}
</div> </div>
); );
} }

View File

@@ -1,8 +1,8 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { HireWizardProvider } from "@/components/hire/HireWizardContext"; import { HireWizardProvider } from "@/components/hire/HireWizardContext";
import { Sidebar } from "@/components/shell/Sidebar"; import { AppShell } from "@/components/shell/AppShell";
import { Topbar } from "@/components/shell/Topbar"; import { loadOpenNotes } from "@/lib/notes";
import { loadOpenPositions } from "@/lib/positions"; import { loadOpenPositions } from "@/lib/positions";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
@@ -22,23 +22,18 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
const userLabel = profile.full_name || profile.email || user.email || ""; const userLabel = profile.full_name || profile.email || user.email || "";
const [openPositions, locationsRes, draftsRes] = await Promise.all([ const [openPositions, locationsRes, draftsRes, openNotes] = await Promise.all([
loadOpenPositions(supabase), loadOpenPositions(supabase),
supabase.from("locations").select("id, name, country").order("name"), 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 }), supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
loadOpenNotes(supabase),
]); ]);
return ( return (
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}> <HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
<div className="flex min-h-screen"> <AppShell userLabel={userLabel} openNotes={openNotes}>
<Sidebar /> {children}
<div className="ml-[236px] flex flex-1 flex-col"> </AppShell>
<Topbar userLabel={userLabel} />
<main className="flex-1 px-6 py-6">
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
</main>
</div>
</div>
</HireWizardProvider> </HireWizardProvider>
); );
} }

View File

@@ -1,22 +1,28 @@
import { Suspense } from "react";
import { OrgChartClient } from "@/components/orgchart/OrgChartClient"; import { OrgChartClient } from "@/components/orgchart/OrgChartClient";
import { todayIso } from "@/lib/format";
import { loadOrgAsOf } from "@/lib/orgchart-data";
import { loadOpenPositions } from "@/lib/positions"; import { loadOpenPositions } from "@/lib/positions";
import { parseIsoDateParam } from "@/lib/reports";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
export default async function OrgChartPage() { type SearchParams = { asOf?: string; focus?: string };
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export default async function OrgChartPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams;
const today = todayIso();
const asOf = parseIsoDateParam(params.asOf) ?? today;
// Only ever used to match against ids already on the page, but validated
// 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 supabase = await createClient();
const [ const [org, { data: divisions }, { data: departments }, { data: teams }, openPositions, { data: reorgScenarios }] =
{ data: employees }, await Promise.all([
{ data: divisions }, loadOrgAsOf(supabase, asOf),
{ data: departments },
{ data: teams },
openPositions,
{ data: reorgScenarios },
] = await Promise.all([
supabase
.from("employees")
.select("id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level")
.in("status", ["Aktiv", "Karenz"]),
supabase.from("divisions").select("*").order("name"), supabase.from("divisions").select("*").order("name"),
supabase.from("departments").select("*"), supabase.from("departments").select("*"),
supabase.from("teams").select("*"), supabase.from("teams").select("*"),
@@ -30,13 +36,20 @@ export default async function OrgChartPage() {
]); ]);
return ( return (
<Suspense>
<OrgChartClient <OrgChartClient
employees={employees ?? []} employees={org.employees}
divisions={divisions ?? []} divisions={divisions ?? []}
departments={departments ?? []} departments={departments ?? []}
teams={teams ?? []} teams={teams ?? []}
openPositions={openPositions} openPositions={openPositions}
reorgScenarios={reorgScenarios ?? []} reorgScenarios={reorgScenarios ?? []}
asOf={asOf}
today={today}
projectedCount={org.projectedCount}
historyStartsAt={org.historyStartsAt}
focusId={focusId}
/> />
</Suspense>
); );
} }

View File

@@ -1,12 +1,9 @@
import Link from "next/link"; import Link from "next/link";
import { DraftsCard } from "@/components/dashboard/DraftsCard"; import { DraftsCard } from "@/components/dashboard/DraftsCard";
import { actionBadgeStyle } from "@/lib/colors"; import { actionBadgeStyle } from "@/lib/colors";
import { fmtDate } from "@/lib/format"; import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import { fetchAllRows } from "@/lib/supabase/query";
function isoDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
const TONE_TEXT: Record<string, string> = { const TONE_TEXT: Record<string, string> = {
default: "text-ink", default: "text-ink",
@@ -45,13 +42,15 @@ export default async function DashboardPage() {
.order("updated_at", { ascending: false }) .order("updated_at", { ascending: false })
: { data: [] }; : { data: [] };
const today = new Date(); // Built as strings, not by round-tripping a local Date through
const todayIso = isoDate(today); // toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
const yearStart = isoDate(new Date(today.getFullYear(), 0, 1)); // the previous year in UTC, which shifted the whole YTD window a day early
const yearEnd = isoDate(new Date(today.getFullYear(), 11, 31)); // and dropped 31 December from it entirely.
const in60 = new Date(today); const today = todayIso();
in60.setDate(in60.getDate() + 60); const year = today.slice(0, 4);
const in60Iso = isoDate(in60); const yearStart = `${year}-01-01`;
const yearEnd = `${year}-12-31`;
const in60Iso = addDaysIso(today, 60);
const [ const [
activeCountRes, activeCountRes,
@@ -59,9 +58,9 @@ export default async function DashboardPage() {
hiresYtdRes, hiresYtdRes,
exitsYtdRes, exitsYtdRes,
openPositionsRes, openPositionsRes,
fteRowsRes, fteRows,
divisionsRes, divisionsRes,
headcountRowsRes, headcountRows,
upcomingHiresRes, upcomingHiresRes,
upcomingExitsRes, upcomingExitsRes,
upcomingReturnsRes, upcomingReturnsRes,
@@ -80,27 +79,27 @@ export default async function DashboardPage() {
.gte("exit_date", yearStart) .gte("exit_date", yearStart)
.lte("exit_date", yearEnd), .lte("exit_date", yearEnd),
supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"), supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
supabase.from("employees").select("weekly_hours").in("status", ["Aktiv", "Karenz"]), fetchAllRows(() => supabase.from("employees").select("weekly_hours").in("status", ["Aktiv", "Karenz"]).order("id")),
supabase.from("divisions").select("id, name"), supabase.from("divisions").select("id, name"),
supabase.from("employees").select("division_id").in("status", ["Aktiv", "Karenz"]), fetchAllRows(() => supabase.from("employees").select("division_id").in("status", ["Aktiv", "Karenz"]).order("id")),
supabase supabase
.from("employees") .from("employees")
.select("id, first_name, last_name, entry_date") .select("id, first_name, last_name, entry_date")
.eq("status", "Geplant") .eq("status", "Geplant")
.gte("entry_date", todayIso) .gte("entry_date", today)
.lte("entry_date", in60Iso), .lte("entry_date", in60Iso),
supabase supabase
.from("employees") .from("employees")
.select("id, first_name, last_name, exit_date") .select("id, first_name, last_name, exit_date")
.not("exit_date", "is", null) .not("exit_date", "is", null)
.gte("exit_date", todayIso) .gte("exit_date", today)
.lte("exit_date", in60Iso), .lte("exit_date", in60Iso),
supabase supabase
.from("employees") .from("employees")
.select("id, first_name, last_name, karenz_return_date") .select("id, first_name, last_name, karenz_return_date")
.eq("status", "Karenz") .eq("status", "Karenz")
.not("karenz_return_date", "is", null) .not("karenz_return_date", "is", null)
.gte("karenz_return_date", todayIso) .gte("karenz_return_date", today)
.lte("karenz_return_date", in60Iso), .lte("karenz_return_date", in60Iso),
supabase supabase
.from("employee_history") .from("employee_history")
@@ -110,10 +109,10 @@ export default async function DashboardPage() {
.limit(10), .limit(10),
]); ]);
const fte = (fteRowsRes.data ?? []).reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5; const fte = fteRows.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
const headcountByDivision = new Map<string, number>(); const headcountByDivision = new Map<string, number>();
for (const row of headcountRowsRes.data ?? []) { for (const row of headcountRows) {
if (!row.division_id) continue; if (!row.division_id) continue;
headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1); headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
} }

View File

@@ -1,73 +1,21 @@
import { PositionsPageClient } from "@/components/positions/PositionsPageClient"; import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
import { daysBetween } from "@/lib/format"; import { daysBetweenIso, toIsoDate } from "@/lib/format";
import { loadOpenPositions } from "@/lib/positions"; import { loadOpenPositions } from "@/lib/positions";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
export default async function PositionsPage() { export default async function PositionsPage() {
const supabase = await createClient(); const supabase = await createClient();
const [openPositions, { data: divisions }, { data: departments }, { data: teams }, { data: activeEmployees }, { data: leads }] = // Teams are only needed for the "Position ausschreiben" dialog's team
await Promise.all([ // 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), loadOpenPositions(supabase),
supabase.from("divisions").select("*").order("name"), supabase.from("teams").select("*").order("name"),
supabase.from("departments").select("*"),
supabase.from("teams").select("*"),
supabase.from("employees").select("team_id, division_id, weekly_hours").in("status", ["Aktiv", "Karenz"]),
supabase
.from("employees")
.select("id, first_name, last_name, team_id, division_id, org_level, is_lead")
.eq("status", "Aktiv")
.or("is_lead.eq.true,org_level.eq.0"),
]); ]);
const teamStats = new Map<string, { headcount: number; fte: number }>(); const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(toIsoDate(p.created_at)) }));
const divisionHeadcount = new Map<string, number>();
for (const e of activeEmployees ?? []) {
if (e.team_id) {
const s = teamStats.get(e.team_id) ?? { headcount: 0, fte: 0 };
s.headcount += 1;
s.fte += Number(e.weekly_hours) / 38.5;
teamStats.set(e.team_id, s);
}
if (e.division_id) {
divisionHeadcount.set(e.division_id, (divisionHeadcount.get(e.division_id) ?? 0) + 1);
}
}
const divisionHeadByDivision = new Map<string, { id: string; name: string }>(); return <PositionsPageClient openPositions={openPositionsWithDays} teams={teams ?? []} />;
const teamLeadByTeam = new Map<string, { id: string; name: string }>();
for (const p of leads ?? []) {
const name = `${p.first_name} ${p.last_name}`;
if (p.org_level === 1 && p.division_id) divisionHeadByDivision.set(p.division_id, { id: p.id, name });
if (p.is_lead && p.team_id) teamLeadByTeam.set(p.team_id, { id: p.id, name });
}
const openPositionCountByTeam = new Map<string, number>();
for (const pos of openPositions) {
openPositionCountByTeam.set(pos.team_id, (openPositionCountByTeam.get(pos.team_id) ?? 0) + 1);
}
const divisionCards = (divisions ?? []).map((div) => ({
...div,
head: divisionHeadByDivision.get(div.id) ?? null,
headcount: divisionHeadcount.get(div.id) ?? 0,
departments: (departments ?? [])
.filter((d) => d.division_id === div.id)
.map((dept) => ({
...dept,
teams: (teams ?? [])
.filter((t) => t.department_id === dept.id)
.map((t) => ({
...t,
lead: teamLeadByTeam.get(t.id) ?? null,
headcount: teamStats.get(t.id)?.headcount ?? 0,
fte: teamStats.get(t.id)?.fte ?? 0,
openCount: openPositionCountByTeam.get(t.id) ?? 0,
})),
})),
}));
const openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetween(p.created_at) }));
return <PositionsPageClient openPositions={openPositionsWithDays} divisionCards={divisionCards} teams={teams ?? []} />;
} }

View File

@@ -1,9 +1,22 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { ReportsPageClient } from "@/components/reports/ReportsPageClient"; import { ReportsPageClient } from "@/components/reports/ReportsPageClient";
import { aggregateEvents, aggregateReport, sumValues, totalForRows, type EventGroupDimension, type GroupDimension, type Measure } from "@/lib/reports"; import {
aggregateEvents,
aggregateReport,
parseEventDateParam,
parseEventGroupDimension,
parseEventSplitDimension,
parseEventType,
parseGroupDimension,
parseIsoDateParam,
parseMeasure,
parseMode,
parseSplitDimension,
sumValues,
totalForRows,
} from "@/lib/reports";
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data"; import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import type { HistoryEventType } from "@/lib/supabase/types";
type SearchParams = { type SearchParams = {
mode?: string; mode?: string;
@@ -23,7 +36,7 @@ type SearchParams = {
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) { export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
const params = await searchParams; const params = await searchParams;
const supabase = await createClient(); const supabase = await createClient();
const mode = params.mode === "events" ? "events" : "snapshot"; const mode = parseMode(params.mode);
const [{ lookups, divisions, locations }, { data: userRes }] = await Promise.all([loadOrgLookups(supabase), supabase.auth.getUser()]); const [{ lookups, divisions, locations }, { data: userRes }] = await Promise.all([loadOrgLookups(supabase), supabase.auth.getUser()]);
const user = userRes.user; const user = userRes.user;
@@ -32,12 +45,20 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
: { data: [] }; : { data: [] };
if (mode === "events") { if (mode === "events") {
const group = (params.group as EventGroupDimension) || "event_type"; const group = parseEventGroupDimension(params.group);
const split = (params.split as EventGroupDimension) || undefined; const split = parseEventSplitDimension(params.split);
const eventType = (params.eventType as HistoryEventType) || undefined; const eventType = parseEventType(params.eventType);
const from = parseEventDateParam(params.from);
const to = parseEventDateParam(params.to);
const events = await loadEventHistory(supabase, { eventType, division: params.division, location: params.location, from: params.from, to: params.to }); const events = await loadEventHistory(supabase, {
const rows = aggregateEvents(events, group, split ?? null, lookups); eventType: eventType ?? undefined,
division: params.division,
location: params.location,
from,
to,
});
const rows = aggregateEvents(events, group, split, lookups);
const total = sumValues(rows); const total = sumValues(rows);
return ( return (
@@ -47,7 +68,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
eventGroup={group} eventGroup={group}
eventSplit={split ?? ""} eventSplit={split ?? ""}
eventType={eventType ?? ""} eventType={eventType ?? ""}
eventFilters={{ division: params.division ?? "", location: params.location ?? "", from: params.from ?? "", to: params.to ?? "" }} eventFilters={{ division: params.division ?? "", location: params.location ?? "", from: from ?? "", to: to ?? "" }}
rows={rows} rows={rows}
total={total} total={total}
recordCount={events.length} recordCount={events.length}
@@ -59,10 +80,10 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
); );
} }
const measure = (params.measure as Measure) || "headcount"; const measure = parseMeasure(params.measure);
const group = (params.group as GroupDimension) || "division"; const group = parseGroupDimension(params.group);
const split = (params.split as GroupDimension) || undefined; const split = parseSplitDimension(params.split);
const asOf = params.asOf || undefined; const asOf = parseIsoDateParam(params.asOf);
const employees = await loadSnapshotEmployees(supabase, { const employees = await loadSnapshotEmployees(supabase, {
division: params.division, division: params.division,
@@ -71,7 +92,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
employment: params.employment, employment: params.employment,
asOf, asOf,
}); });
const rows = aggregateReport(employees, measure, group, split ?? null, lookups, asOf); const rows = aggregateReport(employees, measure, group, split, lookups, asOf);
const total = totalForRows(rows, measure); const total = totalForRows(rows, measure);
return ( return (

View File

@@ -1,11 +1,24 @@
import { login, logout } from "@/actions/auth"; import { login, logout } 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.
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.",
} as const;
type ErrorCode = keyof typeof ERROR_MESSAGES;
type LoginPageProps = { type LoginPageProps = {
searchParams: Promise<{ error?: string }>; searchParams: Promise<{ error?: string }>;
}; };
export default async function LoginPage({ searchParams }: LoginPageProps) { export default async function LoginPage({ searchParams }: LoginPageProps) {
const { error } = await searchParams; const params = await searchParams;
const code = params.error && Object.hasOwn(ERROR_MESSAGES, params.error) ? (params.error as ErrorCode) : null;
const error = code ? ERROR_MESSAGES[code] : null;
return ( return (
<div className="flex min-h-screen items-center justify-center bg-surface px-4"> <div className="flex min-h-screen items-center justify-center bg-surface px-4">
@@ -16,11 +29,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
{error && ( {error && (
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text"> <div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
{error} {error}
{code === "no_hr_access" && (
<form action={logout} className="mt-2"> <form action={logout} className="mt-2">
<button type="submit" className="text-xs font-semibold underline hover:no-underline"> <button type="submit" className="text-xs font-semibold underline hover:no-underline">
Abmelden und mit anderem Konto versuchen Abmelden und mit anderem Konto versuchen
</button> </button>
</form> </form>
)}
</div> </div>
)} )}

View File

@@ -1,9 +1,11 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { deriveStatusAsOf, parseStatuses, type OrgLookups } from "@/lib/reports"; import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
import { loadOrgLookups, type ReportFilters } from "@/lib/reports-data"; 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 { createClient } from "@/lib/supabase/server";
import type { Database, EmploymentType } from "@/lib/supabase/types"; import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
@@ -14,19 +16,12 @@ type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
// than the live `status` column — see deriveStatusAsOf. // than the live `status` column — see deriveStatusAsOf.
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const supabase = await createClient(); const supabase = await createClient();
const { const denied = await requireHrUser(supabase);
data: { user }, if (denied) return denied;
} = 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 });
}
const params = request.nextUrl.searchParams; const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
const asOf = params.get("asOf") ?? undefined; const asOf = parseIsoDateParam(params.get("asOf"));
const filters: ReportFilters = { const filters: ReportFilters = {
division: params.get("division") ?? undefined, division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined, location: params.get("location") ?? undefined,
@@ -36,25 +31,25 @@ export async function GET(request: NextRequest) {
const statuses = parseStatuses(filters.status); const statuses = parseStatuses(filters.status);
let query = supabase.from("employees").select("*").order("last_name"); 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.division) query = query.eq("division_id", filters.division);
if (filters.location) query = query.eq("location_id", filters.location); if (filters.location) query = query.eq("location_id", filters.location);
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
if (!asOf) query = query.in("status", statuses); if (!asOf) query = query.in("status", statuses);
return query;
const [{ data: employees, error }, { lookups }, { data: allEmployees }] = await Promise.all([
query,
loadOrgLookups(supabase),
supabase.from("employees").select("id, first_name, last_name"),
]);
if (error) {
console.error("employees export query failed:", error);
return NextResponse.json({ error: "Interner Fehler." }, { status: 500 });
} }
const managerName = new Map((allEmployees ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`])); const [employees, { lookups }, allEmployees, dependentsCounts] = await Promise.all([
const rows = asOf ? (employees ?? []).filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : (employees ?? []); fetchAllRows(employeeQuery),
const columns = employeeExportColumns(lookups, managerName, asOf); loadOrgLookups(supabase),
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
loadDependentsCounts(supabase),
]);
const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
const rows = asOf ? employees.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))) : employees;
const columns = employeeExportColumns(lookups, managerName, dependentsCounts, asOf);
const filename = exportFilename("mitarbeiter-export", format); const filename = exportFilename("mitarbeiter-export", format);
const body = format === "xlsx" ? await toXlsx(rows, columns, "Mitarbeiter") : toCsv(rows, columns); const body = format === "xlsx" ? await toXlsx(rows, columns, "Mitarbeiter") : toCsv(rows, columns);
@@ -63,7 +58,14 @@ export async function GET(request: NextRequest) {
return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) }); return new NextResponse(new Blob([body as BlobPart]), { headers: exportResponseHeaders(filename, format) });
} }
function employeeExportColumns(lookups: OrgLookups, managerName: Map<string, string>, asOf?: string): ExportColumn<EmployeeRow>[] { const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
function employeeExportColumns(
lookups: OrgLookups,
managerName: Map<string, string>,
dependentsCounts: Map<string, number>,
asOf?: string
): ExportColumn<EmployeeRow>[] {
const columns: ExportColumn<EmployeeRow>[] = [ const columns: ExportColumn<EmployeeRow>[] = [
{ header: "Pers.-Nr.", get: (e) => e.personnel_number }, { header: "Pers.-Nr.", get: (e) => e.personnel_number },
{ header: "Vorname", get: (e) => e.first_name }, { header: "Vorname", get: (e) => e.first_name },
@@ -73,6 +75,8 @@ function employeeExportColumns(lookups: OrgLookups, managerName: Map<string, str
{ header: "SV-Nummer", get: (e) => e.sv_nummer }, { header: "SV-Nummer", get: (e) => e.sv_nummer },
{ header: "Staatsbürgerschaft", get: (e) => e.nationality }, { header: "Staatsbürgerschaft", get: (e) => e.nationality },
{ header: "Adresse", get: (e) => e.address }, { header: "Adresse", get: (e) => e.address },
{ header: "Postleitzahl", get: (e) => e.postal_code },
{ header: "Ort", get: (e) => e.city },
{ header: "Wohnsitzland", get: (e) => e.address_country }, { header: "Wohnsitzland", get: (e) => e.address_country },
{ header: "E-Mail", get: (e) => e.email }, { header: "E-Mail", get: (e) => e.email },
{ header: "Telefon", get: (e) => e.phone }, { header: "Telefon", get: (e) => e.phone },
@@ -86,8 +90,17 @@ function employeeExportColumns(lookups: OrgLookups, managerName: Map<string, str
{ header: "Org-Level", get: (e) => e.org_level }, { header: "Org-Level", get: (e) => e.org_level },
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type }, { header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
{ header: "Wochenstunden", get: (e) => e.weekly_hours }, { header: "Wochenstunden", get: (e) => e.weekly_hours },
// work_days is stored in click order (see RoleEmploymentFields), not
// guaranteed chronological — re-sort Mo→So for the export.
{ header: "Arbeitstage", get: (e) => [...e.work_days].sort((a, b) => WEEKDAY_ORDER.indexOf(a as Weekday) - WEEKDAY_ORDER.indexOf(b as Weekday)).join(", ") },
{ header: "Vertragsart", get: (e) => e.contract_type }, { header: "Vertragsart", get: (e) => e.contract_type },
{ header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" }, { header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" },
{ header: "Angestellte:r / Arbeiter:in", get: (e) => e.worker_type },
{ header: "Kollektivvertrag", get: (e) => e.collective_agreement },
{ header: "Betriebsrat", get: (e) => e.is_betriebsrat },
{ header: "Dienstwagen", get: (e) => e.has_dienstwagen },
{ header: "Laterale Führung", get: (e) => e.is_laterale_fuehrung },
{ header: "C-Level", get: (e) => e.is_c_level },
{ header: "Paygrade", get: (e) => e.paygrade }, { header: "Paygrade", get: (e) => e.paygrade },
{ header: "Herkunft", get: (e) => e.source }, { header: "Herkunft", get: (e) => e.source },
{ header: "Status", get: (e) => e.status }, { header: "Status", get: (e) => e.status },
@@ -96,6 +109,7 @@ function employeeExportColumns(lookups: OrgLookups, managerName: Map<string, str
{ header: "Austrittsgrund", get: (e) => e.exit_reason }, { header: "Austrittsgrund", get: (e) => e.exit_reason },
{ header: "Karenzbeginn", get: (e) => e.karenz_start_date, kind: "date" }, { header: "Karenzbeginn", get: (e) => e.karenz_start_date, kind: "date" },
{ header: "Karenz-Rückkehrdatum", get: (e) => e.karenz_return_date, kind: "date" }, { header: "Karenz-Rückkehrdatum", get: (e) => e.karenz_return_date, kind: "date" },
{ header: "Anzahl Angehörige", get: (e) => dependentsCounts.get(e.id) ?? 0 },
]; ];
if (asOf) { if (asOf) {
const statusIndex = columns.findIndex((c) => c.header === "Status"); const statusIndex = columns.findIndex((c) => c.header === "Status");

View File

@@ -1,9 +1,9 @@
import { NextResponse, type NextRequest } from "next/server"; import { NextResponse, type NextRequest } from "next/server";
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export"; import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
import { EVENT_TYPE_LABELS, type OrgLookups, type ReportEvent } from "@/lib/reports"; import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data"; import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/supabase/auth";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import type { HistoryEventType } from "@/lib/supabase/types";
// Full raw event-log dump — one row per employee_history entry in the // Full raw event-log dump — one row per employee_history entry in the
// selected period (default: current year), every event type unless one is // selected period (default: current year), every event type unless one is
@@ -11,30 +11,21 @@ import type { HistoryEventType } from "@/lib/supabase/types";
// placement (see loadEventHistory). // placement (see loadEventHistory).
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const supabase = await createClient(); const supabase = await createClient();
const { const denied = await requireHrUser(supabase);
data: { user }, if (denied) return denied;
} = 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 });
}
const params = request.nextUrl.searchParams; const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
const eventType = (params.get("eventType") as HistoryEventType) || undefined; const eventType = parseEventType(params.get("eventType"));
const from = params.get("from") ?? undefined;
const to = params.get("to") ?? undefined;
const [{ lookups }, events] = await Promise.all([ const [{ lookups }, events] = await Promise.all([
loadOrgLookups(supabase), loadOrgLookups(supabase),
loadEventHistory(supabase, { loadEventHistory(supabase, {
eventType, eventType: eventType ?? undefined,
division: params.get("division") ?? undefined, division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined, location: params.get("location") ?? undefined,
from, from: parseEventDateParam(params.get("from")),
to, to: parseEventDateParam(params.get("to")),
}), }),
]); ]);

View File

@@ -6,6 +6,16 @@ import {
EVENT_GROUP_LABELS, EVENT_GROUP_LABELS,
GROUP_LABELS, GROUP_LABELS,
MEASURE_LABELS, MEASURE_LABELS,
parseEventDateParam,
parseEventGroupDimension,
parseEventSplitDimension,
parseEventType,
parseGroupDimension,
parseIsoDateParam,
parseMeasure,
parseMode,
parseSplitDimension,
sortKeysForDimension,
sumValues, sumValues,
totalForRows, totalForRows,
type EventGroupDimension, type EventGroupDimension,
@@ -14,8 +24,8 @@ import {
type ReportRow, type ReportRow,
} from "@/lib/reports"; } from "@/lib/reports";
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data"; import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
import { requireHrUser } from "@/lib/supabase/auth";
import { createClient } from "@/lib/supabase/server"; import { createClient } from "@/lib/supabase/server";
import type { HistoryEventType } from "@/lib/supabase/types";
// Exports exactly the pivot table currently on screen (same mode/measure or // Exports exactly the pivot table currently on screen (same mode/measure or
// event-type/group/split/filters, read from the query string the client // event-type/group/split/filters, read from the query string the client
@@ -23,19 +33,12 @@ import type { HistoryEventType } from "@/lib/supabase/types";
// per split value if a split is active. // per split value if a split is active.
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const supabase = await createClient(); const supabase = await createClient();
const { const denied = await requireHrUser(supabase);
data: { user }, if (denied) return denied;
} = 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 });
}
const params = request.nextUrl.searchParams; const params = request.nextUrl.searchParams;
const format = params.get("format") === "xlsx" ? "xlsx" : "csv"; const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
const mode = params.get("mode") === "events" ? "events" : "snapshot"; const mode = parseMode(params.get("mode"));
const { lookups } = await loadOrgLookups(supabase); const { lookups } = await loadOrgLookups(supabase);
let rows: ReportRow[]; let rows: ReportRow[];
@@ -43,24 +46,24 @@ export async function GET(request: NextRequest) {
let filenameBase: string; let filenameBase: string;
if (mode === "events") { if (mode === "events") {
const group = (params.get("group") as EventGroupDimension) || "event_type"; const group = parseEventGroupDimension(params.get("group"));
const split = (params.get("split") as EventGroupDimension) || null; const split = parseEventSplitDimension(params.get("split"));
const eventType = (params.get("eventType") as HistoryEventType) || undefined; const eventType = parseEventType(params.get("eventType"));
const events = await loadEventHistory(supabase, { const events = await loadEventHistory(supabase, {
eventType, eventType: eventType ?? undefined,
division: params.get("division") ?? undefined, division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined, location: params.get("location") ?? undefined,
from: params.get("from") ?? undefined, from: parseEventDateParam(params.get("from")),
to: params.get("to") ?? undefined, to: parseEventDateParam(params.get("to")),
}); });
rows = aggregateEvents(events, group, split, lookups); rows = aggregateEvents(events, group, split, lookups);
columns = eventReportColumns(rows, group, split, sumValues(rows)); columns = eventReportColumns(rows, group, split, sumValues(rows));
filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`; filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`;
} else { } else {
const measure = (params.get("measure") as Measure) || "headcount"; const measure = parseMeasure(params.get("measure"));
const group = (params.get("group") as GroupDimension) || "division"; const group = parseGroupDimension(params.get("group"));
const split = (params.get("split") as GroupDimension) || null; const split = parseSplitDimension(params.get("split"));
const asOf = params.get("asOf") ?? undefined; const asOf = parseIsoDateParam(params.get("asOf"));
const employees = await loadSnapshotEmployees(supabase, { const employees = await loadSnapshotEmployees(supabase, {
division: params.get("division") ?? undefined, division: params.get("division") ?? undefined,
location: params.get("location") ?? undefined, location: params.get("location") ?? undefined,
@@ -89,7 +92,7 @@ function snapshotReportColumns(
): ExportColumn<ReportRow>[] { ): ExportColumn<ReportRow>[] {
const columns: ExportColumn<ReportRow>[] = [{ header: GROUP_LABELS[group], get: (r) => r.key }]; const columns: ExportColumn<ReportRow>[] = [{ header: GROUP_LABELS[group], get: (r) => r.key }];
if (split) { if (split) {
const splitKeys = Array.from(new Set(rows.flatMap((r) => r.split?.map((s) => s.key) ?? []))); const splitKeys = sortKeysForDimension(Array.from(new Set(rows.flatMap((r) => r.split?.map((s) => s.key) ?? []))), split);
for (const key of splitKeys) { for (const key of splitKeys) {
columns.push({ header: key, get: (r) => Math.round((r.split?.find((s) => s.key === key)?.value ?? 0) * 100) / 100 }); columns.push({ header: key, get: (r) => Math.round((r.split?.find((s) => s.key === key)?.value ?? 0) * 100) / 100 });
} }

View File

@@ -42,4 +42,96 @@
body { body {
background-color: var(--color-surface); background-color: var(--color-surface);
color: var(--color-ink); color: var(--color-ink);
/* iOS bounce-scrolls the whole document behind fixed overlays (drawer,
modal) and shows the white page edge; contain keeps the rubber band
inside the scroller that actually overflowed. */
overscroll-behavior-y: none;
}
/* Mobile browsers zoom the page when a focused input is under ~16px. Every
form control in this app is text-sm (14px), so without this, tapping any
field on iOS Safari jumps the layout. */
@media (max-width: 767px) {
input,
select,
textarea {
font-size: 16px;
}
}
/* Touch devices have no hover, so the grey flash Chrome/Safari paint on tap
is the only press feedback — but their default is a hard blue box. */
@media (hover: none) {
* {
-webkit-tap-highlight-color: color-mix(in srgb, var(--color-brand-500) 12%, transparent);
}
}
/* ── React Flow chrome ─────────────────────────────────────────────
The library ships a generic grey control panel; these bring it onto the
app's own palette and give the buttons a real touch target. */
.orgchart-canvas .react-flow__controls {
gap: 2px;
border-radius: var(--radius);
border: 1px solid var(--color-border);
background: #fff;
box-shadow: 0 2px 10px rgb(45 28 38 / 0.08);
overflow: hidden;
}
.orgchart-canvas .react-flow__controls-button {
width: 32px;
height: 32px;
border: none;
border-bottom: 1px solid var(--color-border-subtle);
background: #fff;
color: var(--color-ink-body);
}
.orgchart-canvas .react-flow__controls-button:last-child {
border-bottom: none;
}
.orgchart-canvas .react-flow__controls-button:hover {
background: var(--color-brand-100);
color: var(--color-brand-700);
}
.orgchart-canvas .react-flow__controls-button svg {
fill: currentColor;
max-width: 14px;
max-height: 14px;
}
.orgchart-canvas .react-flow__minimap {
border-radius: var(--radius);
border: 1px solid var(--color-border);
background: #fff;
box-shadow: 0 2px 10px rgb(45 28 38 / 0.08);
}
/* Node selection has to stay enabled (it is what gives the card pointer
events at all — see GraphOrgChart), but the card carries its own hover and
match styling, so React Flow's default selected/focus outline is noise. */
.orgchart-canvas .react-flow__node.selected,
.orgchart-canvas .react-flow__node:focus,
.orgchart-canvas .react-flow__node:focus-visible {
outline: none;
box-shadow: none;
}
.orgchart-canvas .react-flow__attribution {
background: transparent;
font-size: 10px;
}
.orgchart-canvas .react-flow__attribution a {
color: var(--color-ink-muted);
}
/* The minimap costs more screen than it earns on a phone. */
@media (max-width: 1023px) {
.orgchart-canvas .react-flow__minimap {
display: none;
}
} }

View File

@@ -1,4 +1,4 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import { Nunito } from "next/font/google"; import { Nunito } from "next/font/google";
import { ToastProvider } from "@/components/ui/Toast"; import { ToastProvider } from "@/components/ui/Toast";
import "./globals.css"; import "./globals.css";
@@ -12,6 +12,18 @@ const nunito = Nunito({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Alpenwerk HR", title: "Alpenwerk HR",
description: "HR-Stammdaten- und Organisationsmanagement für Alpenwerk Industrie GmbH", description: "HR-Stammdaten- und Organisationsmanagement für Alpenwerk Industrie GmbH",
// Added to the home screen on iOS this opens without Safari's chrome and
// keeps the app's own name rather than the page title.
appleWebApp: { capable: true, title: "Alpenwerk HR", statusBarStyle: "default" },
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
// The layout extends under the notch and home indicator; every edge that
// matters pads itself back out with env(safe-area-inset-*).
viewportFit: "cover",
themeColor: "#ffffff",
}; };
export default function RootLayout({ export default function RootLayout({
@@ -21,7 +33,7 @@ export default function RootLayout({
}>) { }>) {
return ( return (
<html lang="de-AT" className={`${nunito.variable} h-full antialiased`}> <html lang="de-AT" className={`${nunito.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col font-sans"> <body className="flex min-h-dvh flex-col font-sans">
<ToastProvider>{children}</ToastProvider> <ToastProvider>{children}</ToastProvider>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,144 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { addEmployeeDependent } from "@/actions/employees";
import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format";
import type { RelationshipType } from "@/lib/supabase/types";
const RELATIONSHIPS: RelationshipType[] = ["Ehepartner:in", "Lebenspartner:in", "Kind", "Sonstige"];
export function AddDependentModal({
open,
onClose,
employeeId,
defaultEffectiveDate,
}: {
open: boolean;
onClose: () => void;
employeeId: string;
defaultEffectiveDate?: string;
}) {
const { showToast } = useToast();
const router = useRouter();
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [svNummer, setSvNummer] = useState("");
const [birthDate, setBirthDate] = useState("");
const [relationship, setRelationship] = useState<RelationshipType>("Kind");
const [effectiveDate, setEffectiveDate] = useState(defaultEffectiveDate ?? todayIso());
const [prevDefaultEffectiveDate, setPrevDefaultEffectiveDate] = useState(defaultEffectiveDate);
const [pending, setPending] = useState(false);
// This modal stays mounted while closed, so a mount-time initializer would
// pin the date forever: DatenAendernPanel drives `defaultEffectiveDate` off
// its own "Wirksam ab" field, and changing it there has to reach the field
// below. Same adjust-during-render pattern as CountryPicker.
if (defaultEffectiveDate !== prevDefaultEffectiveDate) {
setPrevDefaultEffectiveDate(defaultEffectiveDate);
if (defaultEffectiveDate) setEffectiveDate(defaultEffectiveDate);
}
function reset() {
setFirstName("");
setLastName("");
setSvNummer("");
setBirthDate("");
setRelationship("Kind");
setEffectiveDate(defaultEffectiveDate ?? todayIso());
}
async function handleSubmit() {
if (!firstName || !lastName || !birthDate || !effectiveDate) {
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
return;
}
setPending(true);
const result = await addEmployeeDependent({
employee_id: employeeId,
first_name: firstName,
last_name: lastName,
relationship,
sv_nummer: svNummer || undefined,
birth_date: birthDate,
effective_date: effectiveDate,
});
setPending(false);
if (result.success) {
showToast("Angehörige:r hinzugefügt.");
router.refresh();
onClose();
reset();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
return (
<Modal
open={open}
onClose={onClose}
title="Angehörige:n hinzufügen"
footer={
<>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
Abbrechen
</button>
<button
onClick={handleSubmit}
disabled={pending}
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
>
Hinzufügen
</button>
</>
}
>
<div className="flex flex-col gap-4">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Wirksam ab*</label>
<input
type="date"
value={effectiveDate}
onChange={(e) => setEffectiveDate(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Vorname*</label>
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Nachname*</label>
<input value={lastName} onChange={(e) => setLastName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">SVNR</label>
<input value={svNummer} onChange={(e) => setSvNummer(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Geburtsdatum*</label>
<input type="date" value={birthDate} onChange={(e) => setBirthDate(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Verwandtschaftsverhältnis*</label>
<select
value={relationship}
onChange={(e) => setRelationship(e.target.value as RelationshipType)}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
{RELATIONSHIPS.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,96 @@
"use client";
import { Plus, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { deleteEmployeeDependent } from "@/actions/employees";
import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format";
import type { Database } from "@/lib/supabase/types";
import { AddDependentModal } from "./AddDependentModal";
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
// `effectiveDate` lets DatenAendernPanel embed this section and drive
// add/remove off its own "Wirksam ab" field; standalone usage (Stammdaten
// tab) omits it and defaults to today, i.e. immediate.
export function AngehoerigeSection({ employeeId, dependents, effectiveDate }: { employeeId: string; dependents: Dependent[]; effectiveDate?: string }) {
const { showToast } = useToast();
const router = useRouter();
const [modalOpen, setModalOpen] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const resolvedEffectiveDate = effectiveDate || todayIso();
async function handleDelete(dependentId: string) {
setDeletingId(dependentId);
const result = await deleteEmployeeDependent({ dependent_id: dependentId, employee_id: employeeId, effective_date: resolvedEffectiveDate });
setDeletingId(null);
if (result.success) {
showToast("Angehörige:r entfernt.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Entfernen.", "error");
}
}
return (
<div className="border-t border-border pt-6">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xs font-bold uppercase tracking-wide text-brand-700">Angehörige</h3>
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => setModalOpen(true)}
className="flex items-center gap-1 text-xs font-semibold text-brand-700 hover:text-brand-600"
>
<Plus className="h-3.5 w-3.5" /> Hinzufügen
</button>
<span className="text-xs text-ink-muted">{dependents.length} Personen</span>
</div>
</div>
{dependents.length === 0 ? (
<p className="text-sm text-ink-muted">Keine Angehörigen hinterlegt.</p>
) : (
<div className="overflow-x-auto rounded border border-border">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b border-border bg-brand-50 text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
<th className="px-4 py-2.5">Name</th>
<th className="px-4 py-2.5">Verhältnis</th>
<th className="px-4 py-2.5">SVNR</th>
<th className="px-4 py-2.5">Geburtsdatum</th>
<th className="px-4 py-2.5" />
</tr>
</thead>
<tbody>
{dependents.map((d) => (
<tr key={d.id} className="border-b border-border last:border-0">
<td className="px-4 py-2.5 font-semibold text-ink">
{d.first_name} {d.last_name}
</td>
<td className="px-4 py-2.5 text-ink-body">{d.relationship}</td>
<td className="px-4 py-2.5 text-ink-body">{d.sv_nummer ?? ""}</td>
<td className="px-4 py-2.5 text-ink-body">{fmtDate(d.birth_date)}</td>
<td className="px-4 py-2.5 text-right">
<button
type="button"
onClick={() => handleDelete(d.id)}
disabled={deletingId === d.id}
aria-label="Angehörige:n entfernen"
className="text-ink-muted hover:text-danger-solid disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<AddDependentModal open={modalOpen} onClose={() => setModalOpen(false)} employeeId={employeeId} defaultEffectiveDate={resolvedEffectiveDate} />
</div>
);
}

View File

@@ -5,7 +5,7 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import { StatusChip } from "@/components/ui/StatusChip"; import { StatusChip } from "@/components/ui/StatusChip";
import { tenure } from "@/lib/format"; import { fmtFullName, tenure } from "@/lib/format";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
import { DatenAendernPanel } from "./panels/DatenAendernPanel"; import { DatenAendernPanel } from "./panels/DatenAendernPanel";
import { KarenzPanel } from "./panels/KarenzPanel"; import { KarenzPanel } from "./panels/KarenzPanel";
@@ -14,6 +14,7 @@ import { RehirePanel } from "./panels/RehirePanel";
import { TerminatePanel } from "./panels/TerminatePanel"; import { TerminatePanel } from "./panels/TerminatePanel";
import { TransferPanel } from "./panels/TransferPanel"; import { TransferPanel } from "./panels/TransferPanel";
import { HistorieTab } from "./tabs/HistorieTab"; import { HistorieTab } from "./tabs/HistorieTab";
import { NotizenTab } from "./tabs/NotizenTab";
import { OrganisationTab } from "./tabs/OrganisationTab"; import { OrganisationTab } from "./tabs/OrganisationTab";
import { StammdatenTab } from "./tabs/StammdatenTab"; import { StammdatenTab } from "./tabs/StammdatenTab";
import { VertragTab } from "./tabs/VertragTab"; import { VertragTab } from "./tabs/VertragTab";
@@ -24,6 +25,8 @@ type Department = Database["public"]["Tables"]["departments"]["Row"];
type Team = Database["public"]["Tables"]["teams"]["Row"]; type Team = Database["public"]["Tables"]["teams"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"]; type Location = Database["public"]["Tables"]["locations"]["Row"];
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"]; type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
type NoteRow = Database["public"]["Tables"]["employee_notes"]["Row"];
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string }; type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean }; type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean };
@@ -32,6 +35,8 @@ type EmployeeDetailProps = {
manager: MiniEmployee | null; manager: MiniEmployee | null;
directReports: MiniEmployee[]; directReports: MiniEmployee[];
history: HistoryRow[]; history: HistoryRow[];
dependents: Dependent[];
notes: NoteRow[];
divisions: Division[]; divisions: Division[];
departments: Department[]; departments: Department[];
teams: Team[]; teams: Team[];
@@ -40,10 +45,10 @@ type EmployeeDetailProps = {
}; };
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null; type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie"] as const; const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie", "HR-Notizen"] as const;
export function EmployeeDetail(props: EmployeeDetailProps) { export function EmployeeDetail(props: EmployeeDetailProps) {
const { employee, manager, directReports, history, divisions, departments, teams, locations } = props; const { employee, manager, directReports, history, dependents, notes, divisions, departments, teams, locations } = props;
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten"); const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
const [panel, setPanel] = useState<PanelType>(null); const [panel, setPanel] = useState<PanelType>(null);
@@ -54,6 +59,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
const location = locations.find((l) => l.id === employee.location_id); const location = locations.find((l) => l.id === employee.location_id);
const isActive = employee.status === "Aktiv" || employee.status === "Karenz"; const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
const canEditData = employee.status !== "Ausgetreten";
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -68,7 +74,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-xl font-extrabold text-ink"> <h2 className="text-xl font-extrabold text-ink">
{employee.first_name} {employee.last_name} {fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)}
</h2> </h2>
<StatusChip status={employee.status} entryDate={employee.entry_date} /> <StatusChip status={employee.status} entryDate={employee.entry_date} />
</div> </div>
@@ -87,14 +93,16 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
<ActionButton icon={ArrowRightLeft} label="Versetzen" onClick={() => setPanel("transfer")} /> <ActionButton icon={ArrowRightLeft} label="Versetzen" onClick={() => setPanel("transfer")} />
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} /> <ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
<ActionButton icon={Clock} label={employee.status === "Karenz" ? "Karenz verwalten" : "Karenz"} onClick={() => setPanel("karenz")} /> <ActionButton icon={Clock} label={employee.status === "Karenz" ? "Karenz verwalten" : "Karenz"} onClick={() => setPanel("karenz")} />
<ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} /> </>
)}
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
{isActive && (
<button <button
onClick={() => setPanel("terminate")} onClick={() => setPanel("terminate")}
className="flex items-center gap-1.5 rounded border border-danger-solid px-3 py-1.5 text-sm font-semibold text-danger-solid hover:bg-danger-bg" className="flex items-center gap-1.5 rounded border border-danger-solid px-3 py-1.5 text-sm font-semibold text-danger-solid hover:bg-danger-bg"
> >
<XCircle className="h-4 w-4" /> Austritt <XCircle className="h-4 w-4" /> Austritt
</button> </button>
</>
)} )}
{employee.status === "Ausgetreten" && ( {employee.status === "Ausgetreten" && (
<button <button
@@ -117,16 +125,19 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink" tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink"
}`} }`}
> >
{t} {t === "HR-Notizen" ? `HR-Notizen ${notes.length}` : t}
</button> </button>
))} ))}
</div> </div>
<div className="rounded border border-border bg-white p-6"> <div className="rounded border border-border bg-white p-6">
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} />} {tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} dependents={dependents} />}
{tab === "Vertrag" && <VertragTab employee={employee} />} {tab === "Vertrag" && <VertragTab employee={employee} />}
{tab === "Organisation" && <OrganisationTab manager={manager} directReports={directReports} breadcrumb={breadcrumb} />} {tab === "Organisation" && (
<OrganisationTab employeeId={employee.id} manager={manager} directReports={directReports} breadcrumb={breadcrumb} />
)}
{tab === "Historie" && <HistorieTab history={history} />} {tab === "Historie" && <HistorieTab history={history} />}
{tab === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
</div> </div>
<TransferPanel <TransferPanel
@@ -140,7 +151,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
/> />
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} /> <PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} /> <KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
<DatenAendernPanel open={panel === "daten"} onClose={() => setPanel(null)} employee={employee} /> <DatenAendernPanel open={panel === "daten"} onClose={() => setPanel(null)} employee={employee} dependents={dependents} />
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} /> <TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} /> <RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
</div> </div>

View File

@@ -0,0 +1,89 @@
"use client";
import type { CollectiveAgreement, Weekday, WorkerType } from "@/lib/supabase/types";
const WEEKDAYS: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
export type RoleEmploymentValue = {
workerType: WorkerType;
collectiveAgreement: CollectiveAgreement;
workDays: Weekday[];
isBetriebsrat: boolean;
hasDienstwagen: boolean;
isLateraleFuehrung: boolean;
isCLevel: boolean;
};
// Shared by the hire wizard (StepVertrag) and DatenAendernPanel — both edit
// the same set of employees columns, just against different local state.
export function RoleEmploymentFields({ value, onChange }: { value: RoleEmploymentValue; onChange: (patch: Partial<RoleEmploymentValue>) => void }) {
function toggleWorkDay(day: Weekday) {
onChange({ workDays: value.workDays.includes(day) ? value.workDays.filter((d) => d !== day) : [...value.workDays, day] });
}
return (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Angestellte:r / Arbeiter:in</label>
<select
value={value.workerType}
onChange={(e) => onChange({ workerType: e.target.value as WorkerType })}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="Angestellte:r">Angestellte:r</option>
<option value="Arbeiter:in">Arbeiter:in</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Kollektivvertrag</label>
<select
value={value.collectiveAgreement}
onChange={(e) => onChange({ collectiveAgreement: e.target.value as CollectiveAgreement })}
className="w-full rounded border border-border px-3 py-2 text-sm"
>
<option value="Handel">Handel</option>
<option value="Süßwaren">Süßwaren</option>
</select>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Arbeitstage</label>
<div className="flex flex-wrap gap-1.5">
{WEEKDAYS.map((day) => (
<button
key={day}
type="button"
onClick={() => toggleWorkDay(day)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold ${
value.workDays.includes(day) ? "bg-brand-500 text-white" : "border border-border text-ink-muted hover:bg-surface"
}`}
>
{day}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={value.isBetriebsrat} onChange={(e) => onChange({ isBetriebsrat: e.target.checked })} />
Betriebsrat
</label>
<label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={value.hasDienstwagen} onChange={(e) => onChange({ hasDienstwagen: e.target.checked })} />
Dienstwagen
</label>
<label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={value.isLateraleFuehrung} onChange={(e) => onChange({ isLateraleFuehrung: e.target.checked })} />
Laterale Führung
</label>
<label className="flex items-center gap-2 text-sm text-ink-body">
<input type="checkbox" checked={value.isCLevel} onChange={(e) => onChange({ isCLevel: e.target.checked })} />
C-Level
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
"use client";
import { Picklist } from "@/components/ui/Picklist";
import { TITLE_PREFIXES, TITLE_SUFFIXES } from "@/lib/titles";
export type TitleValue = { titlePrefix: string[]; titleSuffix: string[] };
// Shared by the hire wizard (StepPerson) and DatenAendernPanel — both edit
// the same title_prefix/title_suffix columns, just against different local
// state, same pattern as RoleEmploymentFields.
export function TitleFields({ value, onChange }: { value: TitleValue; onChange: (patch: Partial<TitleValue>) => void }) {
return (
<div className="flex flex-col gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Titel (vorangestellt)</label>
<Picklist options={TITLE_PREFIXES} value={value.titlePrefix} onChange={(titlePrefix) => onChange({ titlePrefix })} />
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Titel (nachgestellt)</label>
<Picklist options={TITLE_SUFFIXES} value={value.titleSuffix} onChange={(titleSuffix) => onChange({ titleSuffix })} />
</div>
</div>
);
}

View File

@@ -3,27 +3,48 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { changeEmployeeData } from "@/actions/employees"; import { changeEmployeeData } from "@/actions/employees";
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
import { RoleEmploymentFields, type RoleEmploymentValue } from "@/components/employees/RoleEmploymentFields";
import { TitleFields, type TitleValue } from "@/components/employees/TitleFields";
import { CountryPicker } from "@/components/ui/CountryPicker"; import { CountryPicker } from "@/components/ui/CountryPicker";
import { SlideOver } from "@/components/ui/SlideOver"; import { SlideOver } from "@/components/ui/SlideOver";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { UN_COUNTRIES } from "@/lib/countries"; import { UN_COUNTRIES } from "@/lib/countries";
import { fmtFullName, todayIso } from "@/lib/format";
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types"; import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
export function DatenAendernPanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) { export function DatenAendernPanel({
open,
onClose,
employee,
dependents,
}: {
open: boolean;
onClose: () => void;
employee: EmployeeRow;
dependents: Dependent[];
}) {
const { showToast } = useToast(); const { showToast } = useToast();
const router = useRouter(); const router = useRouter();
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
const [effectiveDate, setEffectiveDate] = useState(new Date().toISOString().slice(0, 10)); const [effectiveDate, setEffectiveDate] = useState(todayIso);
const [firstName, setFirstName] = useState(employee.first_name); const [firstName, setFirstName] = useState(employee.first_name);
const [lastName, setLastName] = useState(employee.last_name); const [lastName, setLastName] = useState(employee.last_name);
const [titles, setTitles] = useState<TitleValue>({ titlePrefix: employee.title_prefix ?? [], titleSuffix: employee.title_suffix ?? [] });
function updateTitles(patch: Partial<TitleValue>) {
setTitles((prev) => ({ ...prev, ...patch }));
}
const [gender, setGender] = useState<GenderType>(employee.gender); const [gender, setGender] = useState<GenderType>(employee.gender);
const [birthDate, setBirthDate] = useState(employee.birth_date); const [birthDate, setBirthDate] = useState(employee.birth_date);
const [svNummer, setSvNummer] = useState(employee.sv_nummer ?? ""); const [svNummer, setSvNummer] = useState(employee.sv_nummer ?? "");
const [nationality, setNationality] = useState(employee.nationality); const [nationality, setNationality] = useState(employee.nationality);
const [address, setAddress] = useState(employee.address ?? ""); const [address, setAddress] = useState(employee.address ?? "");
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
const [city, setCity] = useState(employee.city ?? "");
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich"); const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
const [email, setEmail] = useState(employee.email); const [email, setEmail] = useState(employee.email);
const [phone, setPhone] = useState(employee.phone ?? ""); const [phone, setPhone] = useState(employee.phone ?? "");
@@ -33,6 +54,19 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
const [contractType, setContractType] = useState<ContractType>(employee.contract_type); const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? ""); const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
const [role, setRole] = useState<RoleEmploymentValue>({
workerType: employee.worker_type ?? "Angestellte:r",
collectiveAgreement: employee.collective_agreement ?? "Handel",
workDays: employee.work_days ?? ["Mo", "Di", "Mi", "Do", "Fr"],
isBetriebsrat: employee.is_betriebsrat ?? false,
hasDienstwagen: employee.has_dienstwagen ?? false,
isLateraleFuehrung: employee.is_laterale_fuehrung ?? false,
isCLevel: employee.is_c_level ?? false,
});
function updateRole(patch: Partial<RoleEmploymentValue>) {
setRole((prev) => ({ ...prev, ...patch }));
}
function handleEmploymentTypeChange(value: EmploymentType) { function handleEmploymentTypeChange(value: EmploymentType) {
setEmploymentType(value); setEmploymentType(value);
if (value === "Vollzeit") setWeeklyHours("38.5"); if (value === "Vollzeit") setWeeklyHours("38.5");
@@ -47,6 +81,10 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
showToast("Bei befristetem Vertrag ist ein Enddatum erforderlich.", "error"); showToast("Bei befristetem Vertrag ist ein Enddatum erforderlich.", "error");
return; return;
} }
if (role.workDays.length === 0) {
showToast("Mindestens ein Arbeitstag muss ausgewählt sein.", "error");
return;
}
if (!effectiveDate) { if (!effectiveDate) {
showToast("Bitte ein Wirksam-ab-Datum angeben.", "error"); showToast("Bitte ein Wirksam-ab-Datum angeben.", "error");
return; return;
@@ -58,11 +96,15 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
person: { person: {
first_name: firstName, first_name: firstName,
last_name: lastName, last_name: lastName,
title_prefix: titles.titlePrefix,
title_suffix: titles.titleSuffix,
gender, gender,
birth_date: birthDate, birth_date: birthDate,
sv_nummer: svNummer, sv_nummer: svNummer,
nationality, nationality,
address, address,
postal_code: postalCode,
city,
address_country: addressCountry, address_country: addressCountry,
email, email,
phone, phone,
@@ -73,6 +115,15 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
contract_type: contractType, contract_type: contractType,
contract_end_date: contractType === "befristet" ? contractEndDate : "", contract_end_date: contractType === "befristet" ? contractEndDate : "",
}, },
role: {
worker_type: role.workerType,
collective_agreement: role.collectiveAgreement,
work_days: role.workDays,
is_betriebsrat: role.isBetriebsrat,
has_dienstwagen: role.hasDienstwagen,
is_laterale_fuehrung: role.isLateraleFuehrung,
is_c_level: role.isCLevel,
},
}); });
setPending(false); setPending(false);
if (result.success) { if (result.success) {
@@ -89,7 +140,7 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
open={open} open={open}
onClose={onClose} onClose={onClose}
title="Daten ändern" title="Daten ändern"
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`} subtitle={`${fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)} · ${employee.job_title}`}
footer={ footer={
<> <>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
@@ -119,7 +170,7 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
<div> <div>
<h3 className="mb-3 text-sm font-bold text-ink">Person</h3> <h3 className="mb-3 text-sm font-bold text-ink">Person</h3>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Vorname</label> <label className="mb-1 block text-xs font-semibold text-ink-muted">Vorname</label>
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> <input value={firstName} onChange={(e) => setFirstName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
@@ -129,7 +180,8 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
<input value={lastName} onChange={(e) => setLastName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> <input value={lastName} onChange={(e) => setLastName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <TitleFields value={titles} onChange={updateTitles} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Geschlecht</label> <label className="mb-1 block text-xs font-semibold text-ink-muted">Geschlecht</label>
<select value={gender} onChange={(e) => setGender(e.target.value as GenderType)} className="w-full rounded border border-border px-3 py-2 text-sm"> <select value={gender} onChange={(e) => setGender(e.target.value as GenderType)} className="w-full rounded border border-border px-3 py-2 text-sm">
@@ -155,16 +207,24 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
<label className="mb-1 block text-xs font-semibold text-ink-muted">Staatsbürgerschaft</label> <label className="mb-1 block text-xs font-semibold text-ink-muted">Staatsbürgerschaft</label>
<CountryPicker value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" /> <CountryPicker value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
</div> </div>
<div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Adresse</label> <label className="mb-1 block text-xs font-semibold text-ink-muted">Adresse (Straße und Hausnummer)</label>
<input value={address} onChange={(e) => setAddress(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> <input value={address} onChange={(e) => setAddress(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div> </div>
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] gap-3">
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Postleitzahl</label>
<input value={postalCode} onChange={(e) => setPostalCode(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
<div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Ort</label>
<input value={city} onChange={(e) => setCity(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div> <div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">Land</label> <label className="mb-1 block text-xs font-semibold text-ink-muted">Land</label>
<CountryPicker value={addressCountry} onChange={setAddressCountry} countries={UN_COUNTRIES} placeholder="Land suchen…" /> <CountryPicker value={addressCountry} onChange={setAddressCountry} countries={UN_COUNTRIES} placeholder="Land suchen…" />
</div> </div>
</div>
<div> <div>
<label className="mb-1 block text-xs font-semibold text-ink-muted">E-Mail</label> <label className="mb-1 block text-xs font-semibold text-ink-muted">E-Mail</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
@@ -225,6 +285,13 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
)} )}
</div> </div>
</div> </div>
<div>
<h3 className="mb-3 text-sm font-bold text-ink">Rolle & Anstellung</h3>
<RoleEmploymentFields value={role} onChange={updateRole} />
</div>
<AngehoerigeSection employeeId={employee.id} dependents={dependents} effectiveDate={effectiveDate} />
</div> </div>
</SlideOver> </SlideOver>
); );

View File

@@ -1,11 +1,11 @@
import { actionBadgeStyle } from "@/lib/colors"; import { actionBadgeStyle } from "@/lib/colors";
import { fmtDate } from "@/lib/format"; import { fmtDate, todayIso } from "@/lib/format";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"]; type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
export function HistorieTab({ history }: { history: HistoryRow[] }) { export function HistorieTab({ history }: { history: HistoryRow[] }) {
const today = new Date().toISOString().slice(0, 10); const today = todayIso();
if (history.length === 0) { if (history.length === 0) {
return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>; return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>;

View File

@@ -0,0 +1,134 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { addEmployeeNote, completeEmployeeNote } from "@/actions/employees";
import { useToast } from "@/components/ui/Toast";
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
import { fmtDate } from "@/lib/format";
import type { Database, NoteCategory } from "@/lib/supabase/types";
type Note = Database["public"]["Tables"]["employee_notes"]["Row"];
const CATEGORIES: NoteCategory[] = ["Allgemein", "Vertraulich", "Personalgespräch", "Wiedervorlage", "Lob / Anerkennung"];
export function NotizenTab({ employeeId, notes }: { employeeId: string; notes: Note[] }) {
const { showToast } = useToast();
const router = useRouter();
const [category, setCategory] = useState<NoteCategory>("Allgemein");
const [noteText, setNoteText] = useState("");
const [dueDate, setDueDate] = useState("");
const [pending, setPending] = useState(false);
const [completingId, setCompletingId] = useState<string | null>(null);
async function handleSubmit() {
if (!noteText.trim()) {
showToast("Bitte einen Notiztext eingeben.", "error");
return;
}
setPending(true);
const result = await addEmployeeNote({
employee_id: employeeId,
category,
note_text: noteText.trim(),
due_date: dueDate || undefined,
});
setPending(false);
if (result.success) {
showToast("Notiz hinzugefügt.");
setNoteText("");
setDueDate("");
setCategory("Allgemein");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
async function handleComplete(noteId: string) {
setCompletingId(noteId);
const result = await completeEmployeeNote({ note_id: noteId, employee_id: employeeId });
setCompletingId(null);
if (result.success) {
showToast("Notiz erledigt.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-start gap-2 rounded bg-info-bg px-4 py-3 text-sm text-info-text">
🔒 Interne HR-Notizen nur für die Personalabteilung sichtbar. Jede Notiz wird mit Verfasser:in und Datum protokolliert.
</div>
<div className="flex flex-col gap-3 rounded border border-border p-4">
<h3 className="text-sm font-bold text-ink">Neue Notiz erfassen</h3>
<div>
<textarea
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
rows={3}
placeholder="Notiz zum/zur Mitarbeiter:in … (z. B. Gesprächsinhalt, Vereinbarung, Beobachtung)"
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Kategorie</label>
<select value={category} onChange={(e) => setCategory(e.target.value as NoteCategory)} className="w-full rounded border border-border px-3 py-2 text-sm">
{CATEGORIES.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Wiedervorlage am (optional)</label>
<input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div>
</div>
<div className="flex justify-end">
<button onClick={handleSubmit} disabled={pending} className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50">
Notiz speichern
</button>
</div>
</div>
{notes.length === 0 ? (
<p className="text-sm text-ink-muted">Keine Notizen vorhanden.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{notes.map((n) => (
<li key={n.id} className="py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${NOTE_CATEGORY_STYLES[n.category]}`}>{n.category}</span>
<span className="text-sm font-semibold text-ink">{n.author_name}</span>
<span className="text-xs text-ink-muted">{fmtDate(n.created_at)}</span>
{n.done ? (
<span className="rounded-full bg-success-bg px-2 py-0.5 text-xs font-semibold text-success-text">Erledigt</span>
) : (
<span className="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-semibold text-brand-700">Offen</span>
)}
</div>
<p className="mt-1 text-sm text-ink">{n.note_text}</p>
{n.due_date && !n.done && <p className="mt-1 text-xs text-warning-text">🔔 fällig {fmtDate(n.due_date)}</p>}
{!n.done && (
<button
type="button"
onClick={() => handleComplete(n.id)}
disabled={completingId === n.id}
className="mt-2 text-xs font-semibold text-success-text hover:underline disabled:opacity-50"
>
Erledigt
</button>
)}
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -1,21 +1,35 @@
import { Network } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string }; type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
type OrganisationTabProps = { type OrganisationTabProps = {
employeeId: string;
manager: MiniEmployee | null; manager: MiniEmployee | null;
directReports: MiniEmployee[]; directReports: MiniEmployee[];
breadcrumb: string; breadcrumb: string;
}; };
export function OrganisationTab({ manager, directReports, breadcrumb }: OrganisationTabProps) { export function OrganisationTab({ employeeId, manager, directReports, breadcrumb }: OrganisationTabProps) {
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3> <h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
<p className="mt-1 text-sm text-ink">{breadcrumb}</p> <p className="mt-1 text-sm text-ink">{breadcrumb}</p>
</div> </div>
{/* ?focus= drives the same highlight/auto-expand path the org chart
search already uses, so the person is unfolded and centred on
arrival instead of the user hunting for them. */}
<Link
href={`/orgchart?focus=${employeeId}`}
className="flex items-center gap-1.5 rounded border border-border px-3 py-2 text-sm font-semibold text-ink-body hover:bg-surface"
>
<Network className="h-4 w-4" />
Im Organigramm anzeigen
</Link>
</div>
<div> <div>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Führungskraft</h3> <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Führungskraft</h3>

View File

@@ -1,21 +1,35 @@
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
import { fmtAge, fmtDate } from "@/lib/format"; import { fmtAge, fmtDate } from "@/lib/format";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"]; type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
type Location = Database["public"]["Tables"]["locations"]["Row"]; type Location = Database["public"]["Tables"]["locations"]["Row"];
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
export function StammdatenTab({ employee, location }: { employee: EmployeeRow; location?: Location }) { function formatAddress(employee: EmployeeRow): string {
const cityLine = [employee.postal_code, employee.city].filter(Boolean).join(" ");
return [employee.address, cityLine].filter(Boolean).join(", ") || "";
}
export function StammdatenTab({ employee, location, dependents }: { employee: EmployeeRow; location?: Location; dependents: Dependent[] }) {
// Defensive against a DB that hasn't received the title_prefix/title_suffix
// migration yet — select("*") simply omits unknown columns, so these can
// be undefined rather than the empty array the column default implies.
const titles = [...(employee.title_prefix ?? []), ...(employee.title_suffix ?? [])];
const rows: [string, string][] = [ const rows: [string, string][] = [
["Titel", titles.length > 0 ? titles.join(", ") : ""],
["Geburtsdatum", `${fmtDate(employee.birth_date)} (${fmtAge(employee.birth_date)} Jahre)`], ["Geburtsdatum", `${fmtDate(employee.birth_date)} (${fmtAge(employee.birth_date)} Jahre)`],
["SV-Nummer", employee.sv_nummer ?? ""], ["SV-Nummer", employee.sv_nummer ?? ""],
["Staatsbürgerschaft", employee.nationality], ["Staatsbürgerschaft", employee.nationality],
["E-Mail", employee.email], ["E-Mail", employee.email],
["Telefon", employee.phone ?? ""], ["Telefon", employee.phone ?? ""],
["Standort", location ? `${location.name} (${location.country})` : ""], ["Standort", location ? `${location.name} (${location.country})` : ""],
["Adresse", employee.address ?? ""], ["Adresse", formatAddress(employee)],
["Land", employee.address_country ?? ""],
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"], ["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
]; ];
return ( return (
<div className="flex flex-col gap-6">
<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3"> <dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
{rows.map(([label, value]) => ( {rows.map(([label, value]) => (
<div key={label}> <div key={label}>
@@ -24,5 +38,7 @@ export function StammdatenTab({ employee, location }: { employee: EmployeeRow; l
</div> </div>
))} ))}
</dl> </dl>
<AngehoerigeSection employeeId={employee.id} dependents={dependents} />
</div>
); );
} }

View File

@@ -13,14 +13,23 @@ const PAYGRADE_LABELS: Record<string, string> = {
}; };
export function VertragTab({ employee }: { employee: EmployeeRow }) { export function VertragTab({ employee }: { employee: EmployeeRow }) {
const flags = [
employee.is_betriebsrat && "Betriebsrat",
employee.has_dienstwagen && "Dienstwagen",
employee.is_laterale_fuehrung && "Laterale Führung",
employee.is_c_level && "C-Level",
].filter(Boolean);
const rows: [string, string][] = [ const rows: [string, string][] = [
["Eintrittsdatum", fmtDate(employee.entry_date)], ["Eintrittsdatum", fmtDate(employee.entry_date)],
["Vertragsart", employee.contract_type === "befristet" ? `befristet bis ${fmtDate(employee.contract_end_date)}` : "unbefristet"], ["Vertragsart", employee.contract_type === "befristet" ? `befristet bis ${fmtDate(employee.contract_end_date)}` : "unbefristet"],
["Kollektivvertrag", "Metalltechnische Industrie"], ["Kollektivvertrag", employee.collective_agreement ?? ""],
["Beschäftigungsausmaß", employee.employment_type], ["Beschäftigungsausmaß", employee.employment_type],
["Wochenstunden", `${employee.weekly_hours} h`], ["Wochenstunden", `${employee.weekly_hours} h`],
["Urlaubsanspruch", "25 Tage"], ["Urlaubsanspruch", "25 Tage"],
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade], ["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
["Angestellte:r / Arbeiter:in", employee.worker_type ?? ""],
["Arbeitstage", employee.work_days?.join(", ") || ""],
["Merkmale", flags.length > 0 ? flags.join(", ") : ""],
]; ];
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]); if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);

View File

@@ -51,7 +51,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
const stepValid = [ const stepValid = [
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId), Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId),
Boolean(draft.positionId && draft.besetzung), Boolean(draft.positionId && draft.besetzung),
Boolean(draft.entryDate), Boolean(draft.entryDate && draft.workDays.length > 0),
true, true,
][step]; ][step];
@@ -72,6 +72,8 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
const result = await hireEmployee({ const result = await hireEmployee({
first_name: draft.firstName, first_name: draft.firstName,
last_name: draft.lastName, last_name: draft.lastName,
title_prefix: draft.titlePrefix,
title_suffix: draft.titleSuffix,
gender: draft.gender, gender: draft.gender,
birth_date: draft.birthDate, birth_date: draft.birthDate,
sv_nummer: draft.svNummer || undefined, sv_nummer: draft.svNummer || undefined,
@@ -85,6 +87,13 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
weekly_hours: Number(draft.weeklyHours), weekly_hours: Number(draft.weeklyHours),
paygrade: draft.paygrade, paygrade: draft.paygrade,
source: draft.besetzung, source: draft.besetzung,
worker_type: draft.workerType,
collective_agreement: draft.collectiveAgreement,
work_days: draft.workDays,
is_betriebsrat: draft.isBetriebsrat,
has_dienstwagen: draft.hasDienstwagen,
is_laterale_fuehrung: draft.isLateraleFuehrung,
is_c_level: draft.isCLevel,
}); });
setSubmitting(false); setSubmitting(false);
if (result.success) { if (result.success) {

View File

@@ -1,3 +1,4 @@
import { TitleFields } from "@/components/employees/TitleFields";
import type { HireDraftData } from "./types"; import type { HireDraftData } from "./types";
type StepPersonProps = { type StepPersonProps = {
@@ -9,7 +10,7 @@ type StepPersonProps = {
export function StepPerson({ draft, update, locations }: StepPersonProps) { export function StepPerson({ draft, update, locations }: StepPersonProps) {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<label className="mb-1 block text-sm font-semibold text-ink">Vorname*</label> <label className="mb-1 block text-sm font-semibold text-ink">Vorname*</label>
<input value={draft.firstName} onChange={(e) => update({ firstName: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> <input value={draft.firstName} onChange={(e) => update({ firstName: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
@@ -19,7 +20,8 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
<input value={draft.lastName} onChange={(e) => update({ lastName: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> <input value={draft.lastName} onChange={(e) => update({ lastName: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <TitleFields value={draft} onChange={update} />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<label className="mb-1 block text-sm font-semibold text-ink">Geschlecht*</label> <label className="mb-1 block text-sm font-semibold text-ink">Geschlecht*</label>
<select <select
@@ -40,7 +42,7 @@ export function StepPerson({ draft, update, locations }: StepPersonProps) {
<label className="mb-1 block text-sm font-semibold text-ink">SV-Nummer</label> <label className="mb-1 block text-sm font-semibold text-ink">SV-Nummer</label>
<input value={draft.svNummer} onChange={(e) => update({ svNummer: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> <input value={draft.svNummer} onChange={(e) => update({ svNummer: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<label className="mb-1 block text-sm font-semibold text-ink">E-Mail (privat)</label> <label className="mb-1 block text-sm font-semibold text-ink">E-Mail (privat)</label>
<input type="email" value={draft.email} onChange={(e) => update({ email: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> <input type="email" value={draft.email} onChange={(e) => update({ email: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />

View File

@@ -1,5 +1,6 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import { Lookup } from "@/components/ui/Lookup"; import { Lookup } from "@/components/ui/Lookup";
import { fmtDate } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions"; import type { OpenPositionResolved } from "@/lib/positions";
import type { HireDraftData } from "./types"; import type { HireDraftData } from "./types";
@@ -42,6 +43,7 @@ export function StepPosition({ draft, update, openPositions }: StepPositionProps
<div className="text-xs text-ink-muted"> <div className="text-xs text-ink-muted">
{selected.position_number} · {selected.orgLabel} {selected.position_number} · {selected.orgLabel}
</div> </div>
<div className="text-xs text-ink-muted">Gültig ab {fmtDate(selected.valid_from)}</div>
</div> </div>
<button type="button" onClick={() => update({ positionId: "" })} aria-label="Auswahl aufheben"> <button type="button" onClick={() => update({ positionId: "" })} aria-label="Auswahl aufheben">
<X className="h-4 w-4 text-ink-muted" /> <X className="h-4 w-4 text-ink-muted" />

View File

@@ -1,4 +1,4 @@
import { fmtDate } from "@/lib/format"; import { fmtDate, fmtFullName } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions"; import type { OpenPositionResolved } from "@/lib/positions";
import type { HireDraftData } from "./types"; import type { HireDraftData } from "./types";
@@ -19,8 +19,14 @@ type StepSummaryProps = {
export function StepSummary({ draft, selectedPosition, locations }: StepSummaryProps) { export function StepSummary({ draft, selectedPosition, locations }: StepSummaryProps) {
const location = locations.find((l) => l.id === draft.locationId); const location = locations.find((l) => l.id === draft.locationId);
const flags = [
draft.isBetriebsrat && "Betriebsrat",
draft.hasDienstwagen && "Dienstwagen",
draft.isLateraleFuehrung && "Laterale Führung",
draft.isCLevel && "C-Level",
].filter(Boolean);
const rows: [string, string][] = [ const rows: [string, string][] = [
["Name", `${draft.firstName} ${draft.lastName}`], ["Name", fmtFullName(draft.firstName, draft.lastName, draft.titlePrefix, draft.titleSuffix)],
["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"], ["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"],
["Geburtsdatum", fmtDate(draft.birthDate)], ["Geburtsdatum", fmtDate(draft.birthDate)],
["SV-Nummer", draft.svNummer || ""], ["SV-Nummer", draft.svNummer || ""],
@@ -35,11 +41,15 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"], ["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`], ["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
["Paygrade", PAYGRADE_LABELS[draft.paygrade]], ["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
["Angestellte:r / Arbeiter:in", draft.workerType],
["Kollektivvertrag", draft.collectiveAgreement],
["Arbeitstage", draft.workDays.join(", ") || ""],
["Merkmale", flags.length > 0 ? flags.join(", ") : ""],
]; ];
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<dl className="grid grid-cols-2 gap-x-6 gap-y-3"> <dl className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{rows.map(([label, value]) => ( {rows.map(([label, value]) => (
<div key={label}> <div key={label}>
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{label}</dt> <dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{label}</dt>

View File

@@ -1,3 +1,4 @@
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
import type { PaygradeType } from "@/lib/supabase/types"; import type { PaygradeType } from "@/lib/supabase/types";
import type { HireDraftData } from "./types"; import type { HireDraftData } from "./types";
@@ -20,7 +21,7 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<label className="mb-1 block text-sm font-semibold text-ink">Eintrittsdatum*</label> <label className="mb-1 block text-sm font-semibold text-ink">Eintrittsdatum*</label>
<input type="date" value={draft.entryDate} onChange={(e) => update({ entryDate: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" /> <input type="date" value={draft.entryDate} onChange={(e) => update({ entryDate: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
@@ -48,7 +49,7 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
/> />
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<label className="mb-1 block text-sm font-semibold text-ink">Beschäftigungsausmaß</label> <label className="mb-1 block text-sm font-semibold text-ink">Beschäftigungsausmaß</label>
<select <select
@@ -88,6 +89,11 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
<p className="mt-1 text-xs text-ink-muted">{PAYGRADES.find((p) => p.value === draft.paygrade)?.description}</p> <p className="mt-1 text-xs text-ink-muted">{PAYGRADES.find((p) => p.value === draft.paygrade)?.description}</p>
</div> </div>
<p className="text-xs text-ink-muted">Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.</p> <p className="text-xs text-ink-muted">Es gilt eine Probezeit von 1 Monat gemäß Kollektivvertrag.</p>
<div>
<h3 className="mb-3 text-sm font-bold text-ink">Rolle & Anstellung</h3>
<RoleEmploymentFields value={draft} onChange={update} />
</div>
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import type { ContractType, EmploymentType, GenderType, PaygradeType } from "@/lib/supabase/types"; import type { CollectiveAgreement, ContractType, EmploymentType, GenderType, PaygradeType, Weekday, WorkerType } from "@/lib/supabase/types";
// The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even // The spec's hire wizard field list (§4.4) omits Geschlecht and Standort even
// though both are NOT NULL on employees — added here (defaults keep them // though both are NOT NULL on employees — added here (defaults keep them
@@ -6,6 +6,8 @@ import type { ContractType, EmploymentType, GenderType, PaygradeType } from "@/l
export type HireDraftData = { export type HireDraftData = {
firstName: string; firstName: string;
lastName: string; lastName: string;
titlePrefix: string[];
titleSuffix: string[];
gender: GenderType; gender: GenderType;
birthDate: string; birthDate: string;
svNummer: string; svNummer: string;
@@ -20,11 +22,20 @@ export type HireDraftData = {
employmentType: EmploymentType; employmentType: EmploymentType;
weeklyHours: string; weeklyHours: string;
paygrade: PaygradeType; paygrade: PaygradeType;
workerType: WorkerType;
collectiveAgreement: CollectiveAgreement;
workDays: Weekday[];
isBetriebsrat: boolean;
hasDienstwagen: boolean;
isLateraleFuehrung: boolean;
isCLevel: boolean;
}; };
export const EMPTY_HIRE_DRAFT: HireDraftData = { export const EMPTY_HIRE_DRAFT: HireDraftData = {
firstName: "", firstName: "",
lastName: "", lastName: "",
titlePrefix: [],
titleSuffix: [],
gender: "m", gender: "m",
birthDate: "", birthDate: "",
svNummer: "", svNummer: "",
@@ -39,4 +50,11 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
employmentType: "Vollzeit", employmentType: "Vollzeit",
weeklyHours: "38.5", weeklyHours: "38.5",
paygrade: "B", paygrade: "B",
workerType: "Angestellte:r",
collectiveAgreement: "Handel",
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],
isBetriebsrat: false,
hasDienstwagen: false,
isLateraleFuehrung: false,
isCLevel: false,
}; };

View File

@@ -0,0 +1,76 @@
"use client";
import { CalendarClock } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { fmtDate } from "@/lib/format";
type AsOfPickerProps = {
asOf: string;
today: string;
/** Placements projected from effective-dated changes not yet applied. */
projectedCount: number;
/** Earliest date the assignment history covers; before that it is today's placement. */
historyStartsAt: string | null;
};
export function AsOfPicker({ asOf, today, projectedCount, historyStartsAt }: AsOfPickerProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
function setAsOf(value: string | undefined) {
const sp = new URLSearchParams(searchParams.toString());
if (value && value !== today) sp.set("asOf", value);
else sp.delete("asOf");
router.push(sp.size > 0 ? `${pathname}?${sp}` : pathname, { scroll: false });
}
const isToday = asOf === today;
const isFuture = asOf > today;
// Assignments only started being recorded when the history table was
// introduced; asking for a date before that yields today's placement for
// everyone, which is worth saying out loud rather than quietly implying.
const beforeHistory = historyStartsAt !== null && asOf < historyStartsAt;
return (
<div className="rounded border border-border bg-white p-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<label htmlFor="orgchart-asof" className="flex items-center gap-1.5 text-sm font-semibold text-ink">
<CalendarClock className="h-4 w-4 text-ink-muted" />
Stichtag
</label>
<input
id="orgchart-asof"
type="date"
value={asOf}
onChange={(e) => setAsOf(e.target.value || undefined)}
className="rounded border border-border px-3 py-2 text-sm"
/>
{!isToday && (
<button type="button" onClick={() => setAsOf(undefined)} className="text-xs font-semibold text-brand-700 hover:underline">
Heute
</button>
)}
<span className="text-xs text-ink-muted">
{isToday ? "Aktuelle Organisationsstruktur." : `Struktur zum ${fmtDate(asOf)}.`}
</span>
</div>
{isFuture && (
<p className="mt-2 rounded bg-info-bg px-3 py-2 text-xs text-info-text">
Vorschau: {projectedCount > 0
? `${projectedCount} geplante Versetzung(en)/Reorganisation(en) sind eingerechnet.`
: "Für diesen Zeitraum sind keine Versetzungen vorgemerkt."}{" "}
Ein-/Austritte und Karenzen sind berücksichtigt.
</p>
)}
{beforeHistory && (
<p className="mt-2 rounded bg-warning-bg px-3 py-2 text-xs text-warning-text">
Vor dem {fmtDate(historyStartsAt)} wurde die Zuordnungshistorie noch nicht aufgezeichnet. Wer beschäftigt war,
stimmt; Team und Vorgesetzte zeigen für diesen Stichtag die heutige Zuordnung.
</p>
)}
</div>
);
}

View File

@@ -2,13 +2,21 @@
import { ChevronDown, ChevronRight, Search } from "lucide-react"; import { ChevronDown, ChevronRight, Search } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Avatar } from "@/components/ui/Avatar"; import { Avatar } from "@/components/ui/Avatar";
import type { OrgEmployee } from "./types"; import { SegmentedControl } from "@/components/ui/SegmentedControl";
import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgEmployee } from "./types";
export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) { type ViewMode = "list" | "graph";
const [expanded, setExpanded] = useState<Set<string>>(new Set());
export function EmployeeTree({ employees, focusId = null }: { employees: OrgEmployee[]; focusId?: string | null }) {
// Seeded with the focus target so their own reports are already unfolded;
// the chain *above* them comes from ancestorExpandIds.
const [expanded, setExpanded] = useState<Set<string>>(() => (focusId ? new Set([focusId]) : new Set()));
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [mode, setMode] = useState<ViewMode>("list");
const focusRef = useRef<HTMLDivElement>(null);
const { childrenByManager, totalReportsById, root } = useMemo(() => { const { childrenByManager, totalReportsById, root } = useMemo(() => {
const byManager = new Map<string, OrgEmployee[]>(); const byManager = new Map<string, OrgEmployee[]>();
@@ -19,22 +27,33 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
} }
for (const list of byManager.values()) list.sort((a, b) => a.last_name.localeCompare(b.last_name)); for (const list of byManager.values()) list.sort((a, b) => a.last_name.localeCompare(b.last_name));
// Nothing in the schema forbids a manager_id cycle (A reports to B
// reports to A), and a reorg that moves a lead under one of their own
// reports would create one. Every walk below is recursive, so an
// unguarded cycle is an infinite recursion that hangs the tab rather
// than a wrong number — hence the on-path set.
const totals = new Map<string, number>(); const totals = new Map<string, number>();
function countTotal(id: string): number { function countTotal(id: string, path: Set<string>): number {
if (totals.has(id)) return totals.get(id)!; const memo = totals.get(id);
const direct = byManager.get(id) ?? []; if (memo !== undefined) return memo;
let total = direct.length; if (path.has(id)) return 0;
for (const child of direct) total += countTotal(child.id); path.add(id);
let total = 0;
for (const child of byManager.get(id) ?? []) total += 1 + countTotal(child.id, path);
path.delete(id);
totals.set(id, total); totals.set(id, total);
return total; return total;
} }
for (const e of employees) countTotal(e.id); for (const e of employees) countTotal(e.id, new Set());
return { childrenByManager: byManager, totalReportsById: totals, root: byManager.get("__root__") ?? [] }; return { childrenByManager: byManager, totalReportsById: totals, root: byManager.get("__root__") ?? [] };
}, [employees]); }, [employees]);
const matchIds = useMemo(() => { const matchIds = useMemo(() => {
if (query.trim().length < 2) return null; // A focus target behaves exactly like a search hit — same ancestor
// auto-expand, same ring, same fitView in graph mode — until the user
// starts typing, at which point their own search takes over.
if (query.trim().length < 2) return focusId ? new Set([focusId]) : null;
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
const matches = new Set<string>(); const matches = new Set<string>();
for (const e of employees) { for (const e of employees) {
@@ -47,7 +66,14 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
} }
} }
return matches; return matches;
}, [query, employees]); }, [query, employees, focusId]);
// The chain above the focused person is auto-expanded, so the row only
// exists after that render — scroll once it does.
useEffect(() => {
if (!focusId) return;
focusRef.current?.scrollIntoView({ block: "center", behavior: "smooth" });
}, [focusId, mode]);
const ancestorExpandIds = useMemo(() => { const ancestorExpandIds = useMemo(() => {
if (!matchIds) return null; if (!matchIds) return null;
@@ -55,7 +81,7 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
const toExpand = new Set<string>(); const toExpand = new Set<string>();
for (const id of matchIds) { for (const id of matchIds) {
let current = byId.get(id); let current = byId.get(id);
while (current?.manager_id) { while (current?.manager_id && !toExpand.has(current.manager_id)) {
toExpand.add(current.manager_id); toExpand.add(current.manager_id);
current = byId.get(current.manager_id); current = byId.get(current.manager_id);
} }
@@ -63,21 +89,42 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
return toExpand; return toExpand;
}, [matchIds, employees]); }, [matchIds, employees]);
function toggle(id: string) { // useCallback-stable: GraphOrgChart's layout memo depends on this
// reference, so an unstable function would force a Dagre re-layout on
// every unrelated re-render.
const toggle = useCallback((id: string) => {
setExpanded((prev) => { setExpanded((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
else next.add(id); else next.add(id);
return next; return next;
}); });
} }, []);
function isExpanded(id: string): boolean { const isExpanded = useCallback((id: string): boolean => expanded.has(id) || (ancestorExpandIds?.has(id) ?? false), [expanded, ancestorExpandIds]);
return expanded.has(id) || (ancestorExpandIds?.has(id) ?? false);
}
function renderNode(e: OrgEmployee, depth: number) { const chartTree = useMemo<ChartNode[]>(() => {
const children = childrenByManager.get(e.id) ?? []; function toChartNode(e: OrgEmployee, ancestors: Set<string>): ChartNode {
const children = ancestors.has(e.id) ? [] : (childrenByManager.get(e.id) ?? []);
const total = totalReportsById.get(e.id) ?? 0;
const nextAncestors = new Set(ancestors).add(e.id);
return {
id: e.id,
kind: "person",
label: `${e.first_name} ${e.last_name}`,
sublabel: e.job_title,
href: `/employees/${e.id}`,
avatar: { firstName: e.first_name, lastName: e.last_name },
totalReports: total,
matched: matchIds?.has(e.id) ?? false,
children: children.map((c) => toChartNode(c, nextAncestors)),
};
}
return root.map((r) => toChartNode(r, new Set()));
}, [root, childrenByManager, totalReportsById, matchIds]);
function renderNode(e: OrgEmployee, depth: number, ancestors: Set<string>) {
const children = ancestors.has(e.id) ? [] : (childrenByManager.get(e.id) ?? []);
const hasChildren = children.length > 0; const hasChildren = children.length > 0;
const expandedNow = isExpanded(e.id); const expandedNow = isExpanded(e.id);
const isMatch = matchIds?.has(e.id) ?? false; const isMatch = matchIds?.has(e.id) ?? false;
@@ -86,6 +133,7 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
return ( return (
<div key={e.id}> <div key={e.id}>
<div <div
ref={e.id === focusId ? focusRef : undefined}
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-brand-100" : ""}`} className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-brand-100" : ""}`}
style={{ paddingLeft: depth * 24 + 8 }} style={{ paddingLeft: depth * 24 + 8 }}
> >
@@ -109,7 +157,9 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
</span> </span>
)} )}
</div> </div>
{hasChildren && expandedNow && <div>{children.map((c) => renderNode(c, depth + 1))}</div>} {hasChildren && expandedNow && (
<div>{children.map((c) => renderNode(c, depth + 1, new Set(ancestors).add(e.id)))}</div>
)}
</div> </div>
); );
} }
@@ -140,8 +190,13 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
> >
Alles einklappen Alles einklappen
</button> </button>
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
</div> </div>
<div>{root.map((r) => renderNode(r, 0))}</div> {mode === "list" ? (
<div>{root.map((r) => renderNode(r, 0, new Set()))}</div>
) : (
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} matchedIds={matchIds} />
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,139 @@
"use client";
import "@xyflow/react/dist/style.css";
import {
Background,
BackgroundVariant,
Controls,
MiniMap,
ReactFlow,
ReactFlowProvider,
useEdgesState,
useNodesState,
useReactFlow,
type Edge,
} from "@xyflow/react";
import { useEffect, useMemo } from "react";
import { collectVisible, layoutWithDagre } from "./graphLayout";
import { OrgChartNode, type OrgChartRFNode } from "./OrgChartNode";
import type { ChartNode, ChartNodeKind } from "./types";
// Referentially stable across renders — React Flow treats a new nodeTypes
// object as a change and remounts every custom node otherwise.
const NODE_TYPES = { orgNode: OrgChartNode };
const MINIMAP_COLORS: Record<ChartNodeKind, string> = {
person: "#d6046e",
role: "#5c2e91",
group: "#c9b3c0",
vacancy: "#f6cfe2",
};
export type GraphOrgChartProps = {
tree: ChartNode[];
isExpanded: (id: string) => boolean;
onToggle: (id: string) => void;
matchedIds?: Set<string> | null;
};
export function GraphOrgChart(props: GraphOrgChartProps) {
return (
<ReactFlowProvider>
<GraphOrgChartInner {...props} />
</ReactFlowProvider>
);
}
function GraphOrgChartInner({ tree, isExpanded, onToggle, matchedIds }: GraphOrgChartProps) {
const { visibleNodes, visibleEdges } = useMemo(() => collectVisible(tree, isExpanded), [tree, isExpanded]);
const { rfNodes, rfEdges } = useMemo(() => {
const positions = layoutWithDagre(visibleNodes, visibleEdges);
const rfNodes: OrgChartRFNode[] = visibleNodes.map((n) => ({
id: n.id,
type: "orgNode",
position: positions.get(n.id) ?? { x: 0, y: 0 },
draggable: false,
data: {
chartNode: n,
expanded: n.children.length > 0 && isExpanded(n.id),
hasChildren: n.children.length > 0,
childCount: n.children.length,
onToggle,
},
}));
const rfEdges: Edge[] = visibleEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
type: "smoothstep",
pathOptions: { borderRadius: 14 },
style: { stroke: "#e3cddb", strokeWidth: 1.5 },
}));
return { rfNodes, rfEdges };
}, [visibleNodes, visibleEdges, isExpanded, onToggle]);
const [nodes, setNodes, onNodesChange] = useNodesState(rfNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(rfEdges);
const { fitView } = useReactFlow();
useEffect(() => {
setNodes(rfNodes);
setEdges(rfEdges);
const raf = requestAnimationFrame(() => fitView({ padding: 0.2, duration: 300 }));
return () => cancelAnimationFrame(raf);
}, [rfNodes, rfEdges, setNodes, setEdges, fitView]);
useEffect(() => {
if (!matchedIds || matchedIds.size === 0) return;
const t = setTimeout(() => {
const ids = [...matchedIds].filter((id) => visibleNodes.some((n) => n.id === id));
if (ids.length > 0) fitView({ nodes: ids.map((id) => ({ id })), padding: 0.3, duration: 400 });
}, 150);
return () => clearTimeout(t);
}, [matchedIds, visibleNodes, fitView]);
return (
// dvh, not vh: on iOS/Android the browser chrome collapses on scroll, and
// vh is measured against the *expanded* viewport — the canvas would hang
// off the bottom of the screen for as long as the toolbar is showing.
<div className="orgchart-canvas h-[70dvh] min-h-[420px] w-full overflow-hidden rounded-lg border border-border bg-white">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={NODE_TYPES}
nodesDraggable={false}
nodesConnectable={false}
edgesFocusable={false}
// Deliberately NOT elementsSelectable={false}: React Flow computes
// `hasPointerEvents = isSelectable || isDraggable || onClick || …`
// and sets pointer-events:none on the node wrapper when all of them
// are off — which kills the expand button and the name link inside
// the card. Selection stays on and is just styled away in CSS.
minZoom={0.1}
maxZoom={1.75}
fitView
fitViewOptions={{ padding: 0.2 }}
>
<Background variant={BackgroundVariant.Dots} gap={22} size={1.4} color="#eedde6" />
<Controls showInteractive={false} />
{/* Hidden under lg via CSS: on a phone it would cover a real
fraction of the canvas for little navigational benefit. */}
<MiniMap
pannable
zoomable
ariaLabel="Übersichtskarte"
maskColor="rgba(249, 241, 245, 0.75)"
nodeColor={(n) => MINIMAP_COLORS[(n.data as OrgChartNodeDataLike).chartNode.kind] ?? "#d6046e"}
nodeStrokeWidth={0}
nodeBorderRadius={3}
/>
</ReactFlow>
</div>
);
}
type OrgChartNodeDataLike = { chartNode: { kind: ChartNodeKind } };

View File

@@ -0,0 +1,11 @@
"use client";
import dynamic from "next/dynamic";
// React Flow + Dagre only ship to the client once someone actually switches
// to "Grafisch" — everyone who stays on the (default) list view never loads
// this bundle.
export const LazyGraphOrgChart = dynamic(() => import("./GraphOrgChart").then((m) => m.GraphOrgChart), {
ssr: false,
loading: () => <div className="flex h-[70vh] min-h-[480px] items-center justify-center text-sm text-ink-muted">Lädt Grafik</div>,
});

View File

@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl"; import { SegmentedControl } from "@/components/ui/SegmentedControl";
import type { OpenPositionResolved } from "@/lib/positions"; import type { OpenPositionResolved } from "@/lib/positions";
import { AsOfPicker } from "./AsOfPicker";
import { EmployeeTree } from "./EmployeeTree"; import { EmployeeTree } from "./EmployeeTree";
import { PositionTree } from "./PositionTree"; import { PositionTree } from "./PositionTree";
import { ReorgWorkbench } from "./ReorgWorkbench"; import { ReorgWorkbench } from "./ReorgWorkbench";
@@ -17,10 +18,29 @@ type OrgChartClientProps = {
teams: OrgTeam[]; teams: OrgTeam[];
openPositions: OpenPositionResolved[]; openPositions: OpenPositionResolved[];
reorgScenarios: ReorgScenarioSummary[]; reorgScenarios: ReorgScenarioSummary[];
asOf: string;
today: string;
projectedCount: number;
historyStartsAt: string | null;
/** Arrived via "Im Organigramm anzeigen" — unfold and centre this person. */
focusId: string | null;
}; };
export function OrgChartClient({ employees, divisions, departments, teams, openPositions, reorgScenarios }: OrgChartClientProps) { export function OrgChartClient({
employees,
divisions,
departments,
teams,
openPositions,
reorgScenarios,
asOf,
today,
projectedCount,
historyStartsAt,
focusId,
}: OrgChartClientProps) {
const [view, setView] = useState<View>("ma"); const [view, setView] = useState<View>("ma");
const isToday = asOf === today;
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -33,13 +53,31 @@ export function OrgChartClient({ employees, divisions, departments, teams, openP
{ value: "reo", label: "Reorganisation" }, { value: "reo", label: "Reorganisation" },
]} ]}
/> />
{view === "ma" && <EmployeeTree employees={employees} />}
{view !== "reo" && (
<AsOfPicker asOf={asOf} today={today} projectedCount={projectedCount} historyStartsAt={historyStartsAt} />
)}
{view === "ma" && <EmployeeTree employees={employees} focusId={focusId} />}
{view === "pos" && ( {view === "pos" && (
<PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} /> <PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} />
)} )}
{view === "reo" && ( {view === "reo" &&
(isToday ? (
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} /> <ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
)} ) : (
// A reorg planned against a past or projected roster would be
// applied to the *live* org anyway — better to send the user back
// to today than to let them assemble moves from a roster that is
// not the one the change would hit.
<div className="rounded border border-border bg-white p-6 text-sm text-ink-body">
<p className="font-semibold text-ink">Reorganisation nur zum heutigen Stand</p>
<p className="mt-1 text-ink-muted">
Es ist ein abweichender Stichtag gewählt. Reorganisationen wirken immer auf die aktuelle Struktur wechseln
Sie zurück auf Heute, um eine zu planen.
</p>
</div>
))}
</div> </div>
); );
} }

View File

@@ -0,0 +1,122 @@
"use client";
import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";
import { Building2, ChevronDown, Plus, UserRound } from "lucide-react";
import Link from "next/link";
import { memo } from "react";
import { Avatar } from "@/components/ui/Avatar";
import { NODE_DIMENSIONS } from "./graphLayout";
import type { ChartNode, ChartNodeKind } from "./types";
export type OrgChartNodeData = {
chartNode: ChartNode;
expanded: boolean;
hasChildren: boolean;
childCount: number;
onToggle: (id: string) => void;
};
export type OrgChartRFNode = Node<OrgChartNodeData, "orgNode">;
// One accent colour per node kind, carried by a left stripe. It is what makes
// the four kinds separable at a glance when the chart is zoomed out far
// enough that the text has stopped being legible.
const KIND_ACCENT: Record<ChartNodeKind, string> = {
person: "bg-brand-500",
role: "bg-purple-text",
group: "bg-ink-muted",
vacancy: "bg-brand-200",
};
const KIND_SHELL: Record<ChartNodeKind, string> = {
person: "border-border bg-white",
role: "border-border bg-white",
group: "border-border-subtle bg-surface",
vacancy: "border-dashed border-brand-200 bg-brand-50",
};
// React Flow re-renders node components on every pan/zoom frame — memo is
// required, not just tidy, to keep that smooth at a few hundred nodes.
export const OrgChartNode = memo(function OrgChartNode({ id, data }: NodeProps<OrgChartRFNode>) {
const { chartNode, expanded, hasChildren, childCount, onToggle } = data;
const { kind, label, sublabel, avatar, href, vacant, totalReports } = chartNode;
const isMatch = chartNode.matched ?? false;
const { width, height } = NODE_DIMENSIONS[kind];
const content = (
<>
{kind === "person" && avatar ? (
<Avatar firstName={avatar.firstName} lastName={avatar.lastName} size="sm" />
) : kind === "vacancy" ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-dashed border-brand-200 text-brand-500">
<Plus className="h-3.5 w-3.5" />
</span>
) : kind === "role" ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-purple-bg text-purple-text">
<UserRound className="h-3.5 w-3.5" />
</span>
) : (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white text-ink-muted">
<Building2 className="h-3.5 w-3.5" />
</span>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span title={label} className="truncate text-[13px] font-bold leading-tight text-ink">
{label}
</span>
{sublabel && (
<span
title={sublabel}
className={`truncate text-[11px] leading-tight ${vacant ? "font-semibold text-warning-text" : "text-ink-muted"}`}
>
{sublabel}
</span>
)}
{totalReports !== undefined && totalReports > 0 && (
<span className="mt-0.5 text-[10px] font-semibold uppercase tracking-wide text-ink-muted">
{childCount} direkt · {totalReports} gesamt
</span>
)}
</span>
</>
);
return (
<div
style={{ width, height }}
className={`group relative flex items-center overflow-visible rounded-lg border shadow-sm transition-shadow hover:shadow-md ${
KIND_SHELL[kind]
} ${isMatch ? "ring-2 ring-brand-500 ring-offset-1" : ""}`}
>
<Handle type="target" position={Position.Top} isConnectable={false} className="!invisible" />
{/* Accent stripe, inset so it follows the card's rounded corner. */}
<span className={`absolute inset-y-1.5 left-0 w-1 rounded-r ${KIND_ACCENT[kind]}`} />
{href ? (
<Link href={href} className="nodrag nopan flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">
{content}
</Link>
) : (
<div className="flex min-w-0 flex-1 items-center gap-2.5 py-2 pl-3.5 pr-3">{content}</div>
)}
{/* Overhangs the bottom edge, sitting on the connector to its children —
the conventional org-chart affordance, and a 28px touch target. */}
{hasChildren && (
<button
type="button"
onClick={() => onToggle(id)}
aria-expanded={expanded}
aria-label={expanded ? `${label} zuklappen` : `${label} aufklappen (${childCount})`}
className="nodrag nopan absolute -bottom-3.5 left-1/2 z-10 flex h-7 min-w-7 -translate-x-1/2 items-center justify-center rounded-full border border-border bg-white px-1.5 text-[11px] font-bold text-ink-body shadow-sm transition-colors hover:border-brand-500 hover:bg-brand-500 hover:text-white"
>
{expanded ? <ChevronDown className="h-3.5 w-3.5" /> : childCount}
</button>
)}
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!invisible" />
</div>
);
});

View File

@@ -2,9 +2,13 @@
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useState, type ReactNode } from "react"; import { useCallback, useMemo, useState, type ReactNode } from "react";
import { SegmentedControl } from "@/components/ui/SegmentedControl";
import type { OpenPositionResolved } from "@/lib/positions"; import type { OpenPositionResolved } from "@/lib/positions";
import type { OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types"; import { LazyGraphOrgChart } from "./LazyGraphOrgChart";
import type { ChartNode, OrgDepartment, OrgDivision, OrgEmployee, OrgTeam } from "./types";
type ViewMode = "list" | "graph";
function TreeRow({ function TreeRow({
depth, depth,
@@ -43,17 +47,24 @@ type PositionTreeProps = {
export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) { export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) {
const [expanded, setExpanded] = useState<Set<string>>(new Set(["root"])); const [expanded, setExpanded] = useState<Set<string>>(new Set(["root"]));
const [mode, setMode] = useState<ViewMode>("list");
function toggle(id: string) { // useCallback-stable: GraphOrgChart's layout memo depends on these
// references, so unstable functions would force a Dagre re-layout on
// every unrelated re-render.
const toggle = useCallback((id: string) => {
setExpanded((prev) => { setExpanded((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
else next.add(id); else next.add(id);
return next; return next;
}); });
} }, []);
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
const ceo = employees.find((e) => e.org_level === 0) ?? null; const ceo = employees.find((e) => e.org_level === 0) ?? null;
const { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle } = useMemo(() => {
const divisionHeadByDivision = new Map<string, OrgEmployee>(); const divisionHeadByDivision = new Map<string, OrgEmployee>();
const teamLeadByTeam = new Map<string, OrgEmployee>(); const teamLeadByTeam = new Map<string, OrgEmployee>();
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>(); const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
@@ -67,13 +78,102 @@ export function PositionTree({ employees, divisions, departments, teams, openPos
byTitle.get(e.job_title)!.push(e); byTitle.get(e.job_title)!.push(e);
} }
} }
const openByTeam = new Map<string, OpenPositionResolved[]>(); return { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle };
}, [employees]);
const openByTeam = useMemo(() => {
const map = new Map<string, OpenPositionResolved[]>();
for (const p of openPositions) { for (const p of openPositions) {
if (!openByTeam.has(p.team_id)) openByTeam.set(p.team_id, []); if (!map.has(p.team_id)) map.set(p.team_id, []);
openByTeam.get(p.team_id)!.push(p); 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]);
return ( return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
</div>
{mode === "graph" ? (
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} />
) : (
<div className="rounded border border-border bg-white p-4"> <div className="rounded border border-border bg-white p-4">
{ceo && ( {ceo && (
<TreeRow depth={0} expandable expandedNow={expanded.has("root")} onToggle={() => toggle("root")}> <TreeRow depth={0} expandable expandedNow={expanded.has("root")} onToggle={() => toggle("root")}>
@@ -169,5 +269,7 @@ export function PositionTree({ employees, divisions, departments, teams, openPos
); );
})} })}
</div> </div>
)}
</div>
); );
} }

View File

@@ -189,7 +189,7 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="rounded border border-border bg-white p-4"> <div className="rounded border border-border bg-white p-4">
<h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2> <h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div> <div>
<label className="mb-1 block text-sm font-semibold text-ink">Name der Reorganisation*</label> <label className="mb-1 block text-sm font-semibold text-ink">Name der Reorganisation*</label>
<input value={name} onChange={(e) => setName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> <input value={name} onChange={(e) => setName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />

View File

@@ -0,0 +1,60 @@
import dagre from "@dagrejs/dagre";
import type { ChartNode, ChartNodeKind } from "./types";
// Must match what OrgChartNode actually renders: Dagre reserves exactly this
// much space per node, so a box that grows past it overlaps its neighbour.
export const NODE_DIMENSIONS: Record<ChartNodeKind, { width: number; height: number }> = {
person: { width: 268, height: 80 },
role: { width: 268, height: 80 },
group: { width: 244, height: 62 },
vacancy: { width: 244, height: 62 },
};
export type VisibleEdge = { id: string; source: string; target: string };
// Collapsing a node means excluding its descendants here, not CSS-hiding a
// layout computed for the full tree — Dagre only ever lays out what's
// actually visible, which is what keeps this usable at ~800 employees.
export function collectVisible(tree: ChartNode[], isExpanded: (id: string) => boolean): { visibleNodes: ChartNode[]; visibleEdges: VisibleEdge[] } {
const visibleNodes: ChartNode[] = [];
const visibleEdges: VisibleEdge[] = [];
function walk(n: ChartNode) {
visibleNodes.push(n);
if (n.children.length > 0 && isExpanded(n.id)) {
for (const c of n.children) {
visibleEdges.push({ id: `${n.id}->${c.id}`, source: n.id, target: c.id });
walk(c);
}
}
}
for (const root of tree) walk(root);
return { visibleNodes, visibleEdges };
}
// Dagre returns center points; React Flow positions nodes by their top-left
// corner, hence the width/height/2 offset below.
export function layoutWithDagre(visibleNodes: ChartNode[], visibleEdges: VisibleEdge[]): Map<string, { x: number; y: number }> {
const g = new dagre.graphlib.Graph();
// ranksep leaves room for the expand button that overhangs each node's
// bottom edge; nodesep keeps sibling cards from visually merging.
g.setGraph({ rankdir: "TB", nodesep: 40, ranksep: 88 });
g.setDefaultEdgeLabel(() => ({}));
for (const n of visibleNodes) {
const { width, height } = NODE_DIMENSIONS[n.kind];
g.setNode(n.id, { width, height });
}
for (const e of visibleEdges) g.setEdge(e.source, e.target);
dagre.layout(g);
const positions = new Map<string, { x: number; y: number }>();
for (const n of visibleNodes) {
const { width, height } = NODE_DIMENSIONS[n.kind];
const { x, y } = g.node(n.id);
positions.set(n.id, { x: x - width / 2, y: y - height / 2 });
}
return positions;
}

View File

@@ -15,3 +15,26 @@ 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 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 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 }; export type ReorgScenarioSummary = { id: string; name: string; effective_date: string; applied: boolean; applied_at: string | null };
// Generic tree shape both EmployeeTree and PositionTree map their own data
// into for the graphical (React Flow + Dagre) view — see GraphOrgChart.
// "role" = structural position (Geschäftsführung/Bereichsleitung/Teamleitung),
// "group" = pure grouping label (Abteilung, Jobtitel-Gruppe), "vacancy" = open
// position. EmployeeTree only ever produces "person" nodes.
export type ChartNodeKind = "person" | "role" | "group" | "vacancy";
export type ChartNode = {
id: string;
kind: ChartNodeKind;
label: string;
sublabel?: string;
href?: string;
avatar?: { firstName: string; lastName: string };
badge?: string;
matched?: boolean;
/** A structural role with nobody in it — drawn as an open slot. */
vacant?: boolean;
/** Reports below this node in total, not just direct ones. */
totalReports?: number;
children: ChartNode[];
};

View File

@@ -6,6 +6,7 @@ import { createPosition, searchSuperiors, type SuperiorSearchResult } from "@/ac
import { Lookup } from "@/components/ui/Lookup"; import { Lookup } from "@/components/ui/Lookup";
import { Modal } from "@/components/ui/Modal"; import { Modal } from "@/components/ui/Modal";
import { useToast } from "@/components/ui/Toast"; import { useToast } from "@/components/ui/Toast";
import { todayIso } from "@/lib/format";
import type { Database } from "@/lib/supabase/types"; import type { Database } from "@/lib/supabase/types";
type Team = Database["public"]["Tables"]["teams"]["Row"]; type Team = Database["public"]["Tables"]["teams"]["Row"];
@@ -17,6 +18,7 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
const [isLead, setIsLead] = useState(false); const [isLead, setIsLead] = useState(false);
const [superior, setSuperior] = useState<SuperiorSearchResult | null>(null); const [superior, setSuperior] = useState<SuperiorSearchResult | null>(null);
const [teamId, setTeamId] = useState(""); const [teamId, setTeamId] = useState("");
const [validFrom, setValidFrom] = useState(todayIso);
const [pending, setPending] = useState(false); const [pending, setPending] = useState(false);
function reset() { function reset() {
@@ -24,10 +26,11 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
setIsLead(false); setIsLead(false);
setSuperior(null); setSuperior(null);
setTeamId(""); setTeamId("");
setValidFrom(todayIso());
} }
async function handleSubmit() { async function handleSubmit() {
if (!title || !superior || (isLead && !teamId)) { if (!title || !superior || !validFrom || (isLead && !teamId)) {
showToast("Bitte alle Pflichtfelder ausfüllen.", "error"); showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
return; return;
} }
@@ -37,6 +40,7 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
superior_employee_id: superior.id, superior_employee_id: superior.id,
is_lead: isLead, is_lead: isLead,
team_id: isLead ? teamId : undefined, team_id: isLead ? teamId : undefined,
valid_from: validFrom,
}); });
setPending(false); setPending(false);
if (result.success) { if (result.success) {
@@ -130,6 +134,15 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
</select> </select>
</div> </div>
)} )}
<div>
<label className="mb-1 block text-sm font-semibold text-ink">Gültig ab*</label>
<input
type="date"
value={validFrom}
onChange={(e) => setValidFrom(e.target.value)}
className="w-full rounded border border-border px-3 py-2 text-sm"
/>
</div>
</div> </div>
</Modal> </Modal>
); );

View File

@@ -1,53 +1,51 @@
"use client"; "use client";
import { Plus } from "lucide-react"; import { Plus, Trash2 } from "lucide-react";
import Link from "next/link"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { useHireWizard } from "@/components/hire/HireWizardContext"; import { deletePosition } from "@/actions/positions";
import { useToast } from "@/components/ui/Toast";
import { fmtDate, todayIso } from "@/lib/format";
import type { OpenPositionResolved } from "@/lib/positions"; import type { OpenPositionResolved } from "@/lib/positions";
import { CreatePositionModal } from "./CreatePositionModal"; import { CreatePositionModal } from "./CreatePositionModal";
import { StaffInternallyModal } from "./StaffInternallyModal"; import { StaffInternallyModal } from "./StaffInternallyModal";
type Team = {
id: string;
org_number: string;
name: string;
lead: { id: string; name: string } | null;
headcount: number;
fte: number;
openCount: number;
};
type Department = { id: string; org_number: string; name: string; teams: Team[] };
type DivisionCard = {
id: string;
org_number: string;
name: string;
head: { id: string; name: string } | null;
headcount: number;
departments: Department[];
};
type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number }; type OpenPositionWithDays = OpenPositionResolved & { daysOpen: number };
type PositionsPageClientProps = { type PositionsPageClientProps = {
openPositions: OpenPositionWithDays[]; openPositions: OpenPositionWithDays[];
divisionCards: DivisionCard[];
teams: { id: string; org_number: string; name: string; department_id: string }[]; teams: { id: string; org_number: string; name: string; department_id: string }[];
}; };
export function PositionsPageClient({ openPositions, divisionCards, teams }: PositionsPageClientProps) { export function PositionsPageClient({ openPositions, teams }: PositionsPageClientProps) {
const { openWizard } = useHireWizard(); const { showToast } = useToast();
const router = useRouter();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [staffTarget, setStaffTarget] = useState<{ id: string; title: string; is_lead: boolean } | null>(null); const [staffTarget, setStaffTarget] = useState<{ id: string; title: string; is_lead: boolean } | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const today = todayIso();
async function handleDelete(id: string) {
setDeletingId(id);
const result = await deletePosition(id);
setDeletingId(null);
if (result.success) {
showToast("Position gelöscht.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Löschen.", "error");
}
}
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="rounded border border-border bg-white p-4"> <div className="rounded border border-border bg-white p-4">
<div className="mb-3 flex items-center justify-between"> <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> <h2 className="text-sm font-bold text-ink">Offene Positionen ({openPositions.length})</h2>
<button <button
type="button" type="button"
onClick={() => setCreateOpen(true)} onClick={() => setCreateOpen(true)}
className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-600" className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-600"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Position ausschreiben Position ausschreiben
@@ -57,96 +55,43 @@ export function PositionsPageClient({ openPositions, divisionCards, teams }: Pos
<p className="text-sm text-ink-muted">Derzeit keine offenen Positionen.</p> <p className="text-sm text-ink-muted">Derzeit keine offenen Positionen.</p>
) : ( ) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{openPositions.map((p) => ( {openPositions.map((p) => {
<div key={p.id} className="rounded border border-border p-3"> 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="text-sm font-semibold text-ink">{p.title}</div>
<button
type="button"
onClick={() => handleDelete(p.id)}
disabled={deletingId === p.id}
aria-label="Position löschen"
className="-mr-1 -mt-1 rounded p-2 text-ink-muted hover:text-danger-solid disabled:opacity-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="text-xs text-ink-muted"> <div className="text-xs text-ink-muted">
{p.position_number} · {p.orgLabel} {p.position_number} · {p.orgLabel}
</div> </div>
<div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div> <div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div>
<div className="mt-3 flex gap-2"> {notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
<button <button
type="button" type="button"
onClick={() => setStaffTarget({ id: p.id, title: p.title, is_lead: p.is_lead })} onClick={() => setStaffTarget({ id: p.id, title: p.title, is_lead: p.is_lead })}
className="flex-1 rounded bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-brand-600" disabled={notYetValid}
title={notYetValid ? `Position ist erst ab ${fmtDate(p.valid_from)} gültig.` : undefined}
className="mt-3 w-full rounded bg-brand-500 px-3 py-2 text-xs font-semibold text-white hover:bg-brand-600 disabled:opacity-50"
> >
Intern Besetzen
</button>
<button
type="button"
onClick={() => openWizard({ positionId: p.id })}
className="flex-1 rounded border border-border px-3 py-1.5 text-xs font-semibold text-ink-body hover:bg-surface"
>
Extern
</button> </button>
</div> </div>
</div> );
))} })}
</div> </div>
)} )}
</div> </div>
{divisionCards.map((div) => (
<div key={div.id} className="rounded border border-border bg-white p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 border-b border-border pb-3">
<div>
<div className="text-xs text-ink-muted">{div.org_number}</div>
<div className="text-sm font-bold text-ink">{div.name}</div>
{div.head && (
<Link href={`/employees/${div.head.id}`} className="text-xs text-brand-700 hover:underline">
{div.head.name}
</Link>
)}
</div>
<div className="text-sm font-semibold text-ink">{div.headcount} Mitarbeiter:innen</div>
</div>
<div className="flex flex-col gap-4">
{div.departments.map((dept) => (
<div key={dept.id}>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">
{dept.org_number} · {dept.name}
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="text-left text-xs text-ink-muted">
<th className="py-1 pr-3">Team</th>
<th className="py-1 pr-3">Teamleitung</th>
<th className="py-1 pr-3">Headcount</th>
<th className="py-1 pr-3">FTE</th>
<th className="py-1 pr-3">Offen</th>
</tr>
</thead>
<tbody>
{dept.teams.map((team) => (
<tr key={team.id} className="border-t border-border">
<td className="py-1.5 pr-3">
<div className="text-ink">{team.name}</div>
<div className="text-xs text-ink-muted">{team.org_number}</div>
</td>
<td className="py-1.5 pr-3 text-ink-body">
{team.lead ? (
<Link href={`/employees/${team.lead.id}`} className="hover:text-brand-700 hover:underline">
{team.lead.name}
</Link>
) : (
""
)}
</td>
<td className="py-1.5 pr-3 text-ink-body">{team.headcount}</td>
<td className="py-1.5 pr-3 text-ink-body">{team.fte.toFixed(1)}</td>
<td className="py-1.5 pr-3 font-semibold text-brand-700">{team.openCount || ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
</div>
))}
<CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} /> <CreatePositionModal open={createOpen} onClose={() => setCreateOpen(false)} teams={teams} />
{staffTarget && ( {staffTarget && (
<StaffInternallyModal <StaffInternallyModal

View File

@@ -28,7 +28,7 @@ export function StaffInternallyModal({ open, onClose, positionId, positionTitle,
const result = await staffPositionInternally({ position_id: positionId, employee_id: employee.id }); const result = await staffPositionInternally({ position_id: positionId, employee_id: employee.id });
setPending(false); setPending(false);
if (result.success) { if (result.success) {
showToast(`${employee.first_name} ${employee.last_name} wurde intern besetzt.`); showToast(`${employee.first_name} ${employee.last_name} besetzt die Position.`);
router.refresh(); router.refresh();
onClose(); onClose();
setEmployee(null); setEmployee(null);
@@ -41,7 +41,7 @@ export function StaffInternallyModal({ open, onClose, positionId, positionTitle,
<Modal <Modal
open={open} open={open}
onClose={onClose} onClose={onClose}
title="Intern besetzen" title="Position besetzen"
footer={ footer={
<> <>
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface"> <button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">

View File

@@ -18,6 +18,7 @@ import {
MEASURE_LABELS, MEASURE_LABELS,
parseStatuses, parseStatuses,
REPORT_PRESETS, REPORT_PRESETS,
sortKeysForDimension,
STATUS_OPTIONS, STATUS_OPTIONS,
todayIso, todayIso,
type EventGroupDimension, type EventGroupDimension,
@@ -86,11 +87,11 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
if (v) sp.set(k, v); if (v) sp.set(k, v);
else sp.delete(k); else sp.delete(k);
} }
router.push(`${pathname}?${sp.toString()}`); router.push(`${pathname}?${sp.toString()}`, { scroll: false });
} }
function switchMode(next: "snapshot" | "events") { function switchMode(next: "snapshot" | "events") {
router.push(`${pathname}?mode=${next}`); router.push(`${pathname}?mode=${next}`, { scroll: false });
} }
function toggleStatus(status: string) { function toggleStatus(status: string) {
@@ -106,7 +107,7 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
sp.set("group", preset.group); sp.set("group", preset.group);
if (preset.split) sp.set("split", preset.split); if (preset.split) sp.set("split", preset.split);
if (preset.eventType) sp.set("eventType", preset.eventType); if (preset.eventType) sp.set("eventType", preset.eventType);
router.push(`${pathname}?${sp.toString()}`); router.push(`${pathname}?${sp.toString()}`, { scroll: false });
} }
function applySavedReport(config: Record<string, unknown>) { function applySavedReport(config: Record<string, unknown>) {
@@ -115,7 +116,7 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
if (typeof v === "string" && v) sp.set(k, v); if (typeof v === "string" && v) sp.set(k, v);
} }
if (!sp.get("mode")) sp.set("mode", "snapshot"); if (!sp.get("mode")) sp.set("mode", "snapshot");
router.push(`${pathname}?${sp.toString()}`); router.push(`${pathname}?${sp.toString()}`, { scroll: false });
} }
async function handleConfirmSaveReport() { async function handleConfirmSaveReport() {
@@ -189,6 +190,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
const isAverage = mode === "snapshot" && AVERAGE_MEASURES.includes(props.measure); const isAverage = mode === "snapshot" && AVERAGE_MEASURES.includes(props.measure);
const maxValue = Math.max(1, ...rows.map((r) => r.value)); const maxValue = Math.max(1, ...rows.map((r) => r.value));
const rawSplitKeys = Array.from(new Set(rows.flatMap((r) => r.split?.map((s) => s.key) ?? [])));
const splitKeys = mode === "snapshot" && props.split ? sortKeysForDimension(rawSplitKeys, props.split) : rawSplitKeys;
const showWeekdayMultiCountNote = mode === "snapshot" && (props.group === "weekday" || props.split === "weekday");
const selectedStatuses = mode === "snapshot" ? parseStatuses(props.filters.status) : []; const selectedStatuses = mode === "snapshot" ? parseStatuses(props.filters.status) : [];
const statusExportLabel = selectedStatuses.length === STATUS_OPTIONS.length ? "Alle" : selectedStatuses.join(", "); const statusExportLabel = selectedStatuses.length === STATUS_OPTIONS.length ? "Alle" : selectedStatuses.join(", ");
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -508,10 +512,15 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
</div> </div>
<p className="mb-4 text-2xl font-extrabold text-ink">{totalDisplay}</p> <p className="mb-4 text-2xl font-extrabold text-ink">{totalDisplay}</p>
{showWeekdayMultiCountNote && (
<p className="mb-3 text-xs text-ink-muted">
Mitarbeitende mit mehreren Arbeitstagen zählen bei Wochentag an jedem ihrer Arbeitstage mehrfach Summe und Anteile ergeben daher mehr als den Gesamt-Headcount bzw. 100%.
</p>
)}
{((mode === "snapshot" && props.split) || (mode === "events" && props.eventSplit)) && ( {((mode === "snapshot" && props.split) || (mode === "events" && props.eventSplit)) && (
<div className="mb-3 flex flex-wrap gap-3"> <div className="mb-3 flex flex-wrap gap-3">
{Array.from(new Set(rows.flatMap((r) => r.split?.map((s) => s.key) ?? []))).map((key, i) => ( {splitKeys.map((key, i) => (
<span key={key} className="flex items-center gap-1.5 text-xs text-ink-body"> <span key={key} className="flex items-center gap-1.5 text-xs text-ink-body">
<span className={`h-2.5 w-2.5 rounded-full ${SPLIT_COLORS[i % SPLIT_COLORS.length]}`} /> <span className={`h-2.5 w-2.5 rounded-full ${SPLIT_COLORS[i % SPLIT_COLORS.length]}`} />
{key} {key}

View File

@@ -0,0 +1,26 @@
"use client";
import { useCallback, useState, type ReactNode } from "react";
import type { OpenNote } from "@/lib/notes";
import { Sidebar } from "./Sidebar";
import { Topbar } from "./Topbar";
// Owns the one piece of state the shell needs (is the mobile drawer open),
// so app/(app)/layout.tsx can stay a Server Component and keep doing its
// auth check and data loading on the server.
export function AppShell({ userLabel, openNotes, children }: { userLabel: string; openNotes: OpenNote[]; children: ReactNode }) {
const [navOpen, setNavOpen] = useState(false);
const closeNav = useCallback(() => setNavOpen(false), []);
return (
<div className="min-h-dvh">
<Sidebar open={navOpen} onClose={closeNav} />
<div className="flex min-h-dvh flex-col lg:ml-[236px]">
<Topbar userLabel={userLabel} openNotes={openNotes} onOpenNav={() => setNavOpen(true)} />
<main className="flex-1 px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-6">
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { Bell } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { completeEmployeeNote } from "@/actions/employees";
import { useToast } from "@/components/ui/Toast";
import { NOTE_CATEGORY_STYLES } from "@/lib/colors";
import { fmtDate } from "@/lib/format";
import type { OpenNote } from "@/lib/notes";
// Click-outside mechanics borrowed from CountryPicker (useRef + mousedown
// listener) — without its draft-text reset, which has no equivalent here.
export function NotesBell({ notes }: { notes: OpenNote[] }) {
const { showToast } = useToast();
const router = useRouter();
const [open, setOpen] = useState(false);
const [completingId, setCompletingId] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function onClickOutside(e: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, []);
async function handleComplete(note: OpenNote) {
setCompletingId(note.id);
const result = await completeEmployeeNote({ note_id: note.id, employee_id: note.employee_id });
setCompletingId(null);
if (result.success) {
showToast("Notiz erledigt.");
router.refresh();
} else {
showToast(result.error ?? "Fehler beim Speichern.", "error");
}
}
return (
<div ref={containerRef} className="relative">
<button type="button" onClick={() => setOpen((o) => !o)} aria-label="Meine Notizen" className="relative rounded p-1.5 text-ink-muted hover:bg-surface">
<Bell className="h-4 w-4" />
{notes.length > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-danger-solid px-1 text-[10px] font-bold text-white">
{notes.length}
</span>
)}
</button>
{open && (
<div className="absolute right-0 z-20 mt-2 max-h-[28rem] w-96 overflow-y-auto rounded border border-border bg-white shadow-lg">
<div className="border-b border-border px-4 py-2.5 text-xs font-bold uppercase tracking-wide text-ink-muted">Meine Notizen ({notes.length})</div>
{notes.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-ink-muted">Keine offenen Notizen.</p>
) : (
<ul className="divide-y divide-border">
{notes.map((n) => (
<li key={n.id} className="px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${NOTE_CATEGORY_STYLES[n.category]}`}>{n.category}</span>
<Link href={`/employees/${n.employee_id}`} className="text-sm font-semibold text-ink hover:text-brand-700 hover:underline">
{n.employeeName}
</Link>
<span className="text-xs text-ink-muted">{fmtDate(n.created_at)}</span>
</div>
<p className="mt-1 text-sm text-ink-body">{n.note_text}</p>
{n.due_date && <p className="mt-1 text-xs text-warning-text">🔔 fällig {fmtDate(n.due_date)}</p>}
<button
type="button"
onClick={() => handleComplete(n)}
disabled={completingId === n.id}
className="mt-2 text-xs font-semibold text-success-text hover:underline disabled:opacity-50"
>
Erledigt
</button>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}

View File

@@ -1,34 +1,80 @@
"use client"; "use client";
import { BarChart3, Building2, History, LayoutGrid, Network, Users } from "lucide-react"; import { BarChart3, Building2, History, LayoutGrid, Network, Users, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect } from "react";
const NAV_ITEMS = [ const NAV_ITEMS = [
{ href: "/", label: "Übersicht", icon: LayoutGrid }, { href: "/", label: "Übersicht", icon: LayoutGrid },
{ href: "/employees", label: "Mitarbeiter:innen", icon: Users }, { href: "/employees", label: "Mitarbeiter:innen", icon: Users },
{ href: "/orgchart", label: "Organigramm", icon: Network }, { href: "/orgchart", label: "Organigramm", icon: Network },
{ href: "/positions", label: "Positionen & Bereiche", icon: Building2 }, { href: "/positions", label: "Positionen", icon: Building2 },
{ href: "/reports", label: "Berichte", icon: BarChart3 }, { href: "/reports", label: "Berichte", icon: BarChart3 },
{ href: "/audit", label: "Audit-Log", icon: History }, { href: "/audit", label: "Audit-Log", icon: History },
] as const; ] as const;
export function Sidebar() { export function isActiveRoute(pathname: string, href: string): boolean {
return href === "/" ? pathname === "/" : pathname.startsWith(href);
}
// Permanent rail from lg up, slide-in drawer below it. Same markup either
// way — the breakpoint only changes how the <nav> is positioned — so the
// nav list has exactly one definition.
export function Sidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
const pathname = usePathname(); const pathname = usePathname();
// A route change means the user tapped a nav item; the drawer has to get
// out of the way on its own, since it covers the page it just navigated to.
useEffect(() => {
onClose();
}, [pathname, onClose]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
return ( return (
<nav className="fixed left-0 top-0 flex h-full w-[236px] flex-col border-r border-border bg-white"> <>
<div className="flex h-14 items-center border-b border-border px-5"> <div
onClick={onClose}
aria-hidden
className={`fixed inset-0 z-40 bg-black/40 transition-opacity lg:hidden ${
open ? "opacity-100" : "pointer-events-none opacity-0"
}`}
/>
<nav
aria-label="Hauptnavigation"
className={`fixed left-0 top-0 z-50 flex h-dvh w-[264px] flex-col border-r border-border bg-white transition-transform duration-200 lg:z-30 lg:w-[236px] lg:translate-x-0 ${
open ? "translate-x-0" : "-translate-x-full"
}`}
>
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border pl-5 pr-3">
<span className="text-base font-extrabold text-brand-700">Alpenwerk HR</span> <span className="text-base font-extrabold text-brand-700">Alpenwerk HR</span>
<button
type="button"
onClick={onClose}
aria-label="Navigation schließen"
className="-mr-1 rounded p-2 text-ink-muted hover:bg-surface lg:hidden"
>
<X className="h-5 w-5" />
</button>
</div> </div>
<ul className="flex-1 space-y-1 px-3 py-4"> {/* pb keeps the last item clear of the iOS home indicator. */}
<ul className="flex-1 space-y-1 overflow-y-auto px-3 py-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
{NAV_ITEMS.map(({ href, label, icon: Icon }) => { {NAV_ITEMS.map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href); const active = isActiveRoute(pathname, href);
return ( return (
<li key={href}> <li key={href}>
<Link <Link
href={href} href={href}
className={`flex items-center gap-3 rounded px-3 py-2 text-sm font-semibold transition-colors ${ aria-current={active ? "page" : undefined}
className={`flex items-center gap-3 rounded px-3 py-2.5 text-sm font-semibold transition-colors ${
active ? "bg-brand-100 text-brand-700" : "text-ink-body hover:bg-surface" active ? "bg-brand-100 text-brand-700" : "text-ink-body hover:bg-surface"
}`} }`}
> >
@@ -40,5 +86,6 @@ export function Sidebar() {
})} })}
</ul> </ul>
</nav> </nav>
</>
); );
} }

View File

@@ -1,15 +1,17 @@
"use client"; "use client";
import { LogOut } from "lucide-react"; import { LogOut, Menu } from "lucide-react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { logout } from "@/actions/auth"; import { logout } from "@/actions/auth";
import type { OpenNote } from "@/lib/notes";
import { NewHireButton } from "./NewHireButton"; import { NewHireButton } from "./NewHireButton";
import { NotesBell } from "./NotesBell";
const TITLES: Record<string, string> = { const TITLES: Record<string, string> = {
"/": "Übersicht", "/": "Übersicht",
"/employees": "Mitarbeiter:innen", "/employees": "Mitarbeiter:innen",
"/orgchart": "Organigramm", "/orgchart": "Organigramm",
"/positions": "Positionen & Bereiche", "/positions": "Positionen",
"/reports": "Berichte", "/reports": "Berichte",
"/audit": "Audit-Log", "/audit": "Audit-Log",
}; };
@@ -22,20 +24,37 @@ function titleFor(pathname: string): string {
type TopbarProps = { type TopbarProps = {
userLabel: string; userLabel: string;
openNotes: OpenNote[];
onOpenNav: () => void;
}; };
export function Topbar({ userLabel }: TopbarProps) { export function Topbar({ userLabel, openNotes, onOpenNav }: TopbarProps) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
<header className="flex h-14 items-center justify-between border-b border-border bg-white px-6"> // Sticky, with the notch inset added to its top padding: on an iPhone in
<h1 className="text-base font-bold text-ink">{titleFor(pathname)}</h1> // landscape the bar would otherwise sit under the rounded corner.
<div className="flex items-center gap-4"> <header className="sticky top-0 z-20 flex h-14 shrink-0 items-center justify-between border-b border-border bg-white px-3 pt-[env(safe-area-inset-top)] sm:px-6">
<div className="flex min-w-0 items-center gap-2">
<button
type="button"
onClick={onOpenNav}
aria-label="Navigation öffnen"
className="-ml-1 rounded p-2 text-ink-body hover:bg-surface lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
<h1 className="truncate text-base font-bold text-ink">{titleFor(pathname)}</h1>
</div>
<div className="flex shrink-0 items-center gap-1 sm:gap-4">
<NotesBell notes={openNotes} />
<NewHireButton /> <NewHireButton />
<div className="flex items-center gap-2 border-l border-border pl-4 text-sm"> <div className="flex items-center gap-2 text-sm sm:border-l sm:border-border sm:pl-4">
<span className="font-semibold text-ink">{userLabel}</span> {/* The name is the first thing worth dropping on a narrow screen —
the account is still reachable via the sign-out control. */}
<span className="hidden font-semibold text-ink md:inline">{userLabel}</span>
<form action={logout}> <form action={logout}>
<button type="submit" aria-label="Abmelden" className="ml-2 rounded p-1.5 text-ink-muted hover:bg-surface"> <button type="submit" aria-label="Abmelden" className="rounded p-2 text-ink-muted hover:bg-surface">
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
</button> </button>
</form> </form>

View File

@@ -25,21 +25,27 @@ export function Modal({ open, onClose, title, children, footer, widthClassName =
if (!open) return null; if (!open) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"> // Bottom-aligned on a phone (thumb reach, and it clears the keyboard when
<div className={`flex max-h-[85vh] w-full flex-col rounded bg-white shadow-xl ${widthClassName}`}> // a field is focused), centred from sm up.
<div className="flex items-center justify-between border-b border-border px-6 py-4"> <div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-0 sm:items-center sm:p-4">
<h2 className="text-lg font-bold text-ink">{title}</h2> <div
<button role="dialog"
type="button" aria-modal="true"
onClick={onClose} aria-label={title}
aria-label="Schließen" className={`flex max-h-[92dvh] w-full flex-col rounded-t-xl bg-white shadow-xl sm:max-h-[85dvh] sm:rounded ${widthClassName}`}
className="rounded p-1 text-ink-muted hover:bg-surface"
> >
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3 sm:px-6 sm:py-4">
<h2 className="text-lg font-bold text-ink">{title}</h2>
<button type="button" onClick={onClose} aria-label="Schließen" className="-mr-1 rounded p-2 text-ink-muted hover:bg-surface">
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div> <div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">{children}</div>
{footer && <div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">{footer}</div>} {footer && (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-border px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-4">
{footer}
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,57 @@
import Link from "next/link";
// Shared by the employee list and the audit log, which both used to render
// one link per page: 54 of them for ~800 employees, and an unbounded number
// for the audit log, all in a single overflowing row. This shows the first
// and last page plus a window around the current one, with the gaps elided.
const WINDOW = 2;
function pageItems(current: number, total: number): number[] {
const pages = new Set<number>([1, total]);
for (let p = current - WINDOW; p <= current + WINDOW; p++) {
if (p >= 1 && p <= total) pages.add(p);
}
return [...pages].sort((a, b) => a - b);
}
type PaginationProps = {
page: number;
totalPages: number;
hrefFor: (page: number) => string;
/** Screen-reader name, e.g. "Mitarbeiter:innen" — several lists per app. */
label: string;
};
export function Pagination({ page, totalPages, hrefFor, label }: PaginationProps) {
if (totalPages <= 1) return null;
const current = Math.min(Math.max(1, page), totalPages);
const items = pageItems(current, totalPages);
return (
<nav aria-label={`Seiten ${label}`} className="flex flex-wrap items-center justify-center gap-1 text-sm">
{current > 1 && (
<Link href={hrefFor(current - 1)} rel="prev" className="rounded px-3 py-1 font-semibold text-ink-body hover:bg-surface">
Zurück
</Link>
)}
{items.map((p, i) => (
<span key={p} className="flex items-center gap-1">
{i > 0 && p - items[i - 1] > 1 && <span className="px-1 text-ink-muted"></span>}
<Link
href={hrefFor(p)}
aria-current={p === current ? "page" : undefined}
className={`rounded px-3 py-1 ${p === current ? "bg-brand-500 font-semibold text-white" : "text-ink-body hover:bg-surface"}`}
>
{p}
</Link>
</span>
))}
{current < totalPages && (
<Link href={hrefFor(current + 1)} rel="next" className="rounded px-3 py-1 font-semibold text-ink-body hover:bg-surface">
Weiter
</Link>
)}
</nav>
);
}

View File

@@ -0,0 +1,60 @@
"use client";
import { X } from "lucide-react";
// Dropdown-to-add, chip-to-remove multi-select for short, fixed option
// lists (no search needed) — e.g. academic titles. The dropdown only offers
// options not yet picked; the reset key forces the <select> back to its
// placeholder after each pick instead of showing the just-added option.
export function Picklist({
options,
value,
onChange,
placeholder = "Hinzufügen…",
}: {
options: string[];
value: string[];
onChange: (value: string[]) => void;
placeholder?: string;
}) {
const available = options.filter((o) => !value.includes(o));
function remove(option: string) {
onChange(value.filter((v) => v !== option));
}
return (
<div className="flex flex-col gap-2">
<select
key={value.length}
defaultValue=""
onChange={(e) => {
if (e.target.value) onChange([...value, e.target.value]);
}}
disabled={available.length === 0}
className="w-full rounded border border-border px-3 py-2 text-sm disabled:bg-surface disabled:text-ink-muted"
>
<option value="" disabled>
{available.length > 0 ? placeholder : "Alle Optionen ausgewählt"}
</option>
{available.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
{value.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{value.map((v) => (
<span key={v} className="flex items-center gap-1 rounded-full bg-brand-500 px-3 py-1.5 text-xs font-semibold text-white">
{v}
<button type="button" onClick={() => remove(v)} aria-label={`${v} entfernen`} className="text-white/80 hover:text-white">
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
);
}

View File

@@ -29,26 +29,28 @@ export function SlideOver({ open, onClose, title, subtitle, children, footer }:
onClick={onClose} onClick={onClose}
/> />
<div <div
className={`absolute right-0 top-0 flex h-full w-full max-w-md flex-col bg-white shadow-xl transition-transform duration-200 ${ role="dialog"
aria-modal="true"
aria-label={title}
className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white shadow-xl transition-transform duration-200 ${
open ? "translate-x-0" : "translate-x-full" open ? "translate-x-0" : "translate-x-full"
}`} }`}
> >
<div className="flex items-start justify-between border-b border-border px-6 py-4"> <div className="flex shrink-0 items-start justify-between border-b border-border px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-6 sm:py-4">
<div> <div className="min-w-0">
<h2 className="text-lg font-bold text-ink">{title}</h2> <h2 className="text-lg font-bold text-ink">{title}</h2>
{subtitle && <p className="text-sm text-ink-muted">{subtitle}</p>} {subtitle && <p className="truncate text-sm text-ink-muted">{subtitle}</p>}
</div> </div>
<button <button type="button" onClick={onClose} aria-label="Schließen" className="-mr-1 rounded p-2 text-ink-muted hover:bg-surface">
type="button"
onClick={onClose}
aria-label="Schließen"
className="rounded p-1 text-ink-muted hover:bg-surface"
>
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
</div> </div>
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div> <div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">{children}</div>
{footer && <div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">{footer}</div>} {footer && (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 border-t border-border px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-6 sm:py-4">
{footer}
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { createContext, useCallback, useContext, useState, type ReactNode } from "react"; import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
type ToastVariant = "success" | "error" | "info"; type ToastVariant = "success" | "error" | "info";
type ToastItem = { id: number; message: string; variant: ToastVariant }; type ToastItem = { id: number; message: string; variant: ToastVariant };
@@ -23,8 +23,13 @@ export function ToastProvider({ children }: { children: ReactNode }) {
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000); setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
}, []); }, []);
// This provider wraps the whole app, so an inline object literal here would
// hand every consumer a new context value on each toast and re-render the
// entire tree for a 4-second banner.
const value = useMemo(() => ({ showToast }), [showToast]);
return ( return (
<ToastContext.Provider value={{ showToast }}> <ToastContext.Provider value={value}>
{children} {children}
<div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2"> <div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
{toasts.map((t) => ( {toasts.map((t) => (

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
services:
app:
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"
env_file:
- .env
# Replaces the Vercel Cron job from vercel.json (not available outside
# Vercel): calls the same endpoint on the same daily schedule using the
# same bearer-secret auth the route already expects.
cron:
image: alpine:3.20
restart: unless-stopped
depends_on:
app:
condition: service_healthy
env_file:
- .env
entrypoint: ["/bin/sh", "-c"]
command:
- |
echo "0 3 * * * /bin/sh -c 'wget -q -O- --header=\"Authorization: Bearer \$$CRON_SECRET\" http://app:3000/api/cron/apply-pending-changes >> /var/log/cron.log 2>&1'" > /etc/crontabs/root
crond -f -d 8

96
docs/data-model.md Normal file
View File

@@ -0,0 +1,96 @@
# Datenmodell
Beschreibt das tatsächliche Supabase-Schema (siehe `supabase/migrations/`),
nicht ein generisches HR-Schema. Quelle der Wahrheit sind immer die
Migrationen; dieses Dokument ist eine lesbare Zusammenfassung und wird bei
strukturellen Änderungen mitgepflegt.
## Grundprinzipien
- **Person ist nicht Position.** `employees` (Personen) und `positions`
(Planstellen/Ausschreibungen) sind getrennte Tabellen. Eine Position wird
bei Einstellung mit einer Person verknüpft (`filled_by_employee_id`),
existiert aber unabhängig davon (offene Ausschreibung).
- **History ist append-only.** `employee_history` (Ereignisse pro Person)
und `audit_log` (systemweit, wer hat was wann geändert) haben keine
Update-/Delete-Policy — RLS erlaubt nur `select`/`insert`. Korrekturen
erfolgen durch einen neuen, kompensierenden Eintrag, nie durch Ändern der
Historie (siehe `undo_reorg`, das eine "Reorganisation rückgängig"-Zeile
anhängt statt die ursprünglichen Zeilen zu löschen).
- **Audit-Log ist Pflicht bei Änderungen — und lebt in der Datenbank, nicht
im App-Code.** Jede mutierende SQL-Funktion (`hire_employee`,
`change_employee_data`, `add_employee_dependent`, `add_employee_note`, …)
schreibt ihren `audit_log`-Eintrag in derselben Transaktion wie die
eigentliche Änderung. Das ist bewusst atomar: ein fehlgeschlagener
Audit-Insert lässt die ganze Transaktion fehlschlagen, statt still eine
Änderung ohne Log zu hinterlassen. Es gibt keinen App-seitigen
`writeAuditLog()`-Helper und es sollte auch keinen geben — das würde eine
zweite, nicht-atomare Logging-Quelle neben der bestehenden schaffen.
- **Service-Role-Zugriff ist server-only.** `lib/supabase/admin.ts` ist die
einzige Stelle, die den Service-Role-Key verwendet; `import "server-only"`
macht einen versehentlichen Client-Import zu einem Build-Fehler. Alles
andere läuft über den anon key + RLS.
- **RLS ist bereits aktiv**, nicht nur für die Produktion vorgemerkt: jede
Tabelle hat `enable row level security` plus mindestens eine Policy
(siehe unten). Zusätzlich existieren explizite `grant`-Statements für
`anon`/`authenticated`/`service_role`
(`20260714120500_default_grants.sql`) — ohne die schlägt jede Query auch
mit korrekter RLS-Policy mit "permission denied" fehl, weil Postgres
Objekt-Rechte unabhängig von RLS prüft.
## Zugriffsmodell
Ein einziges Rollenmodell, kein Mehrfach-Rollen-System:
- `profiles.role` ist per Check-Constraint auf den einzigen Wert `'hr'`
fixiert (Migration `20260714120000_hr_only_access.sql`).
- `profiles.is_active` (default `false`) muss zusätzlich wahr sein.
- Die SQL-Funktion `is_hr_user()` (SECURITY DEFINER, vermeidet RLS-Rekursion
auf `profiles`) prüft beides und gated praktisch jede Policy im Schema.
- `proxy.ts` (Next.js Proxy, ehem. Middleware) spiegelt dieselbe Prüfung auf
App-Ebene: nicht eingeloggt → `/login`; eingeloggt aber nicht aktive
HR-Person → `/login` mit Fehlermeldung. Das ist bewusst nur "defense in
depth" — die eigentliche Schranke ist RLS, nicht die UI-Prüfung.
- Es gibt **keine** granulareren Rollen (kein `hr_admin`/`read_only`/
`it_admin`-Split o. Ä.) und aktuell keinen zweiten Anwendungsfall dafür.
Sollte das nötig werden, ist der richtige Ansatz eine neue Migration, die
`is_hr_user()` um echte Rollenspalten erweitert — nicht ein
App-seitiges Rollenmodell, das der DB-Policy-Ebene nicht entspricht.
## Kernentitäten
| Tabelle | Zweck |
|---|---|
| `divisions` / `departments` / `teams` | Org-Hierarchie ("Bereich" 20xx / "Abteilung" 21xx / "Team" 22xx), je mit eindeutiger `org_number`. |
| `locations` | Standorte, an ein Land gebunden (steuert die Standort-Picklist im UI). |
| `profiles` | Ein Datensatz pro Supabase-Auth-User; Rolle + Aktivierungsstatus (siehe oben). |
| `employees` | Zentrale Personentabelle: Stammdaten, Vertrag (Vollzeit/Teilzeit, befristet/unbefristet), Org-Zuordnung, Status (`Aktiv`/`Karenz`/`Geplant`/`Ausgetreten`), Rolle & Anstellung (Angestellte:r/Arbeiter:in, Kollektivvertrag, Arbeitstage, Betriebsrat/Dienstwagen/laterale Führung/C-Level-Flags), akademische Titel (Prefix/Suffix-Arrays). |
| `employee_history` | Append-only Ereignis-Timeline pro Person (fester Enum: Eintritt, Beförderung, Versetzung, Karenz, Vertragsänderung, Stammdatenänderung, Austritt, Wiedereintritt, Reorganisation, Gehaltsanpassung, Rückkehr). |
| `employee_dependents` | Angehörige (Ehepartner:in/Lebenspartner:in/Kind/Sonstige) je Mitarbeiter:in; Edit = Löschen + Neuanlage, kein In-place-Update. |
| `employee_notes` | HR-Notizen je Mitarbeiter:in, add-only, mit optionalem "Wiedervorlage am"-Datum. Bewusst nicht autor-gescoped — jede aktive HR-Person sieht jede offene Notiz ("Meine Notizen" ist ein geteiltes Postfach, kein persönliches). |
| `positions` | Planstellen mit eindeutiger `position_number` (^6\d{7}$), Status `open`/`filled`, `valid_from`-Gültigkeitsfenster. |
| `hire_drafts` | Fortsetzbarer Zwischenstand des Neueinstellungs-Wizards (JSONB-Payload), eigentümer-gescoped. |
| `saved_reports` | Gespeicherte Report-Konfigurationen, eigentümer-gescoped. |
| `pending_org_changes` | Effective-dated (zukünftig wirksame) Änderungen — Versetzung/Beförderung/Karenz-Start/-Rückkehr/Vertragsänderung/Reorg — die erst am `effective_date` angewendet werden. Wird von `apply_due_pending_changes()` verarbeitet, aufgerufen vom Cron-Route-Handler. Entspricht dem, was in generischen HR-Schemata oft "planned_changes" heißt. |
| `audit_log` | Systemweiter, unveränderlicher Audit-Trail (wer/wann/was/an wem). Wird ausschließlich von SQL-Funktionen beschrieben, nie direkt aus der App. |
| `reorg_scenarios` / `reorg_moves` | Persistierte Reorg-Pläne inkl. `undo_snapshot` (Pre-Change-Zustand für die Rückgängig-Funktion). |
## Cron / effective-dated changes
`apply_due_pending_changes()` (SQL, `SECURITY DEFINER`) ist die einzige
Funktion, deren Ausführungsrecht explizit auf `service_role` beschränkt ist
(`revoke ... from public, anon, authenticated; grant ... to service_role`).
Sie wird von `app/api/cron/apply-pending-changes/route.ts` aufgerufen —
täglich per Vercel Cron (`vercel.json`), außerhalb von Vercel per
Ersatz-Scheduler (siehe `DEPLOYMENT.md`, Docker-Cron-Sidecar). Die Route
selbst authentifiziert per `CRON_SECRET`-Bearer-Token, nicht per
Supabase-Session — es gibt keine anfragende Person, nur den Scheduler.
Idempotenz: `pending_org_changes.status` läuft `pending``applied` (oder
`cancelled` bei Reorg-Undo); die Auswahl-Query filtert immer auf
`status = 'pending'`, ein zweiter Lauf wirkt daher auf bereits angewendete
Einträge nicht erneut.
## Bekannte Lücken vor Produktivbetrieb
Siehe README.md → "Known TODOs" für den aktuellen Stand.

113
docs/security-review.md Normal file
View File

@@ -0,0 +1,113 @@
# Security Review
Ergebnis eines gezielten Greps über das gesamte Repository (ohne
`node_modules`) nach neun sicherheitsrelevanten Mustern, mit Bewertung im
jeweiligen Kontext. Stand: 2026-07-24.
## `SUPABASE_SERVICE_ROLE_KEY`
Referenziert in vier Dateien, alle server-seitig / lokal:
- `lib/supabase/admin.ts` — die einzige App-Laufzeit-Verwendung, hinter
`import "server-only"`. Jetzt mit expliziter Fehlermeldung bei fehlendem
Wert statt eines `!`-Non-null-Assertions (siehe Änderungen).
- `supabase/seed.ts`, `tests/integration/helpers.ts` — Node-Skripte
außerhalb des Next.js-Bundles (Seeding bzw. Test-Setup), lesen den Wert
nur aus `process.env`. Unkritisch.
- `.scratch_make_test_hr.mjs` — lokales, nicht eingechecktes
Hilfsskript (liest den Key ebenfalls nur aus `process.env`, kein
Hardcoding). War bisher weder committet noch von `.gitignore` erfasst;
**behoben**`.gitignore` schließt `.scratch_*` jetzt explizit aus, damit
ein künftiges `git add -A` es nicht versehentlich eincheckt.
Keine Fundstelle exponiert den Key im Browser-Bundle oder in einer
API-Response.
## `service_role` (Postgres-Rolle)
9 Treffer, ausschließlich in `supabase/migrations/*.sql` — Standard-Supabase-
Muster:
- `20260714120500_default_grants.sql`: explizite `grant ... to anon,
authenticated, service_role` (nötig, weil RLS Objekt-Rechte nur
einschränkt, nicht ersetzt — ohne dieses Grant schlägt jede Query auch
mit korrekter Policy mit "permission denied" fehl).
- `20260714120200_effective_dating_rpcs.sql` / `20260714120600_...`:
`revoke execute ... from public, anon, authenticated; grant execute ...
to service_role` für `apply_due_pending_changes()` — das ist die
**korrekte** Absicherung: die Funktion darf nur vom Cron-Job (über den
Service-Role-Client) aufgerufen werden, nicht von einer eingeloggten
HR-Person, sonst könnte diese noch nicht fällige Änderungen vorzeitig
erzwingen.
Keine problematische Fundstelle.
## `localStorage`, `sessionStorage`, `document.cookie`
Keine Treffer im gesamten App-Code. Sessions laufen ausschließlich über den
von `@supabase/ssr` verwalteten Cookie-Adapter (`lib/supabase/server.ts`,
`proxy.ts`), nicht über direkten Browser-Storage-Zugriff. Kein Risiko einer
Token-Exponierung über clientseitigen Storage.
## `dangerouslySetInnerHTML`, `innerHTML`
Keine Treffer. Keine rohe HTML-Injection-Fläche im Code.
## `Authorization`
Einzige Fundstelle: `tests/unit/security.test.ts` (prüft den Cron-Route-
Guard). Die Route selbst liest den Header über
`request.headers.get("authorization")` (Kleinschreibung) — HTTP-Header sind
case-insensitiv und `Headers.get()` behandelt sie entsprechend, das ist
korrekt und wird bereits durch drei Tests abgedeckt (fehlender Header,
falscher Wert, nicht konfiguriertes `CRON_SECRET`).
## `audit_logs` / `planned_changes` (aus der Aufgabenstellung)
Keine Treffer unter diesen exakten Namen — das reale Schema heißt
`audit_log` (Singular) bzw. `pending_org_changes`. Siehe
[`docs/data-model.md`](data-model.md) für die vollständige Zuordnung.
**Audit-Log-Abdeckung geprüft:** Jede mutierende Server Action (`actions/
employees.ts`, `actions/positions.ts`, `actions/reorg.ts`) ruft ausschließlich
`supabase.rpc(...)` auf — keine einzige schreibt direkt per
`.from(...).insert()/.update()/.delete()` auf `employees`, `positions`,
`employee_notes`, `employee_dependents` oder `profiles`. Jede der
dahinterliegenden SQL-Funktionen (`hire_employee`, `transfer_employee`,
`promote_employee`, `start_karenz`, `record_karenz_return`,
`change_employee_data`, `apply_reorg`, `undo_reorg`,
`staff_position_internally`, `delete_position`, `add_employee_dependent`,
`delete_employee_dependent`, `add_employee_note`,
`complete_employee_note`) schreibt ihren `audit_log`-Eintrag in derselben
Transaktion wie die eigentliche Datenänderung. Für die aktuell existierenden
Mutationspfade ist damit lückenlos sichergestellt, dass keine Änderung ohne
Audit-Eintrag möglich ist — ein fehlgeschlagener Audit-Insert lässt die
gesamte Transaktion fehlschlagen.
Ausnahme (bewusst, kein Gap): `apply_due_pending_changes()` (vom Cron-Job
aufgerufen) schreibt beim tatsächlichen Anwenden einer fälligen Änderung
keinen zusätzlichen `audit_log`-Eintrag — der Audit-Eintrag für diese
Änderung wurde bereits zum Zeitpunkt der Anforderung geschrieben (von
`transfer_employee`/`start_karenz`/etc.), datiert auf das Wirksamkeitsdatum.
Ein zweiter Eintrag beim tatsächlichen Anwenden würde denselben
Geschäftsvorfall doppelt loggen.
## Warum hier kein neues `lib/audit/audit-log.ts` entstanden ist
Eine App-seitige `writeAuditLog()`-Hilfsfunktion wäre eine zweite,
nicht-transaktionale Logging-Quelle neben der bestehenden — sie könnte
fehlschlagen, nachdem die eigentliche Mutation bereits committet wurde, und
so eine Änderung ohne Audit-Spur hinterlassen. Die bestehende Lösung
(Audit-Insert in derselben SQL-Funktion/Transaktion) ist strenger. Ein
App-seitiger Helfer wäre daher eine Verschlechterung, kein Fix.
## Offene Punkte (siehe README → "Known TODOs")
- Kein Content-Security-Policy-Header (bewusst zurückgestellt, siehe
Kommentar in `next.config.ts` — Skript-/Style-/Connect-Quellen noch nicht
vollständig inventarisiert).
- `.scratch_shots/` enthielt PNG-Screenshots einer Testsitzung mit
Beispiel-Notiztext ("vertrauliches Gespräch zur Verifikation" — Testdaten,
keine echten Personendaten identifiziert). Ordner ist jetzt über
`.gitignore` ausgeschlossen; Inhalt selbst wurde nicht gelöscht (siehe
Hinweis unten).

View File

@@ -1,4 +1,4 @@
import type { EmploymentStatus } from "./supabase/types"; import type { EmploymentStatus, NoteCategory } from "./supabase/types";
const AVATAR_PALETTE = [ const AVATAR_PALETTE = [
"#d6046e", "#d6046e",
@@ -30,7 +30,7 @@ export const STATUS_STYLES: Record<EmploymentStatus, string> = {
type ColorCategory = "success" | "danger" | "warning" | "info" | "purple" | "brand"; type ColorCategory = "success" | "danger" | "warning" | "info" | "purple" | "brand";
const CATEGORY_STYLES: Record<ColorCategory, string> = { export const CATEGORY_STYLES: Record<ColorCategory, string> = {
success: "bg-success-bg text-success-text", success: "bg-success-bg text-success-text",
danger: "bg-danger-bg text-danger-text", danger: "bg-danger-bg text-danger-text",
warning: "bg-warning-bg text-warning-text", warning: "bg-warning-bg text-warning-text",
@@ -61,3 +61,11 @@ const ACTION_CATEGORY: Record<string, ColorCategory> = {
export function actionBadgeStyle(action: string): string { export function actionBadgeStyle(action: string): string {
return CATEGORY_STYLES[ACTION_CATEGORY[action] ?? "brand"]; return CATEGORY_STYLES[ACTION_CATEGORY[action] ?? "brand"];
} }
export const NOTE_CATEGORY_STYLES: Record<NoteCategory, string> = {
Vertraulich: CATEGORY_STYLES.purple,
"Personalgespräch": CATEGORY_STYLES.info,
Wiedervorlage: CATEGORY_STYLES.warning,
"Lob / Anerkennung": CATEGORY_STYLES.success,
Allgemein: "bg-surface text-ink-muted",
};

View File

@@ -1,4 +1,5 @@
import ExcelJS from "exceljs"; import ExcelJS from "exceljs";
import { todayIso } from "./format";
// Shared by every /api/export/* route: define columns once as { header, get }, // Shared by every /api/export/* route: define columns once as { header, get },
// get both a semicolon CSV (Excel-DE friendly) and a real .xlsx workbook from // get both a semicolon CSV (Excel-DE friendly) and a real .xlsx workbook from
@@ -38,8 +39,12 @@ export function toCsv<T>(rows: T[], columns: ExportColumn<T>[]): string {
return "" + lines.join("\r\n"); return "" + lines.join("\r\n");
} }
// Anchored at UTC midnight, not local: ExcelJS converts a JS Date to an Excel
// serial straight off getTime() with no timezone adjustment, so a Date built
// at *local* midnight in a positive-offset zone (Vienna) lands on the previous
// day's serial and every date cell in the workbook renders one day early.
function parseIsoDate(value: string): Date | null { function parseIsoDate(value: string): Date | null {
const d = new Date(`${value}T00:00:00`); const d = new Date(`${value}T00:00:00Z`);
return Number.isNaN(d.getTime()) ? null : d; return Number.isNaN(d.getTime()) ? null : d;
} }
@@ -47,9 +52,12 @@ export async function toXlsx<T>(rows: T[], columns: ExportColumn<T>[], sheetName
const workbook = new ExcelJS.Workbook(); const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet(sheetName.slice(0, 31)); const sheet = workbook.addWorksheet(sheetName.slice(0, 31));
sheet.columns = columns.map((c) => ({ // Keyed by position, not by header text: split columns take their header
// from the data (a team name, a weekday), so two columns can legitimately
// collide — and ExcelJS silently drops the second one when two share a key.
sheet.columns = columns.map((c, i) => ({
header: c.header, header: c.header,
key: c.header, key: String(i),
width: Math.min(40, Math.max(12, c.header.length + 4)), width: Math.min(40, Math.max(12, c.header.length + 4)),
style: c.kind === "date" ? { numFmt: "dd.mm.yyyy" } : undefined, style: c.kind === "date" ? { numFmt: "dd.mm.yyyy" } : undefined,
})); }));
@@ -59,9 +67,9 @@ export async function toXlsx<T>(rows: T[], columns: ExportColumn<T>[], sheetName
for (const row of rows) { for (const row of rows) {
const record: Record<string, string | number | boolean | Date | null> = {}; const record: Record<string, string | number | boolean | Date | null> = {};
for (const c of columns) { for (const [i, c] of columns.entries()) {
const value = c.get(row); const value = c.get(row);
record[c.header] = record[String(i)] =
c.kind === "date" && typeof value === "string" && value c.kind === "date" && typeof value === "string" && value
? (parseIsoDate(value) ?? value) ? (parseIsoDate(value) ?? value)
: typeof value === "string" : typeof value === "string"
@@ -75,9 +83,18 @@ export async function toXlsx<T>(rows: T[], columns: ExportColumn<T>[], sheetName
return new Uint8Array(written); return new Uint8Array(written);
} }
// The base carries values that originate in the query string (event type,
// measure, dimension) and ends up inside a Content-Disposition header, so it
// is reduced to a filename-safe slug here rather than trusted. Callers also
// validate those params; this is the backstop that makes header injection
// impossible regardless.
export function exportFilename(base: string, format: "csv" | "xlsx"): string { export function exportFilename(base: string, format: "csv" | "xlsx"): string {
const today = new Date().toISOString().slice(0, 10); const slug = base
return `${base}-${today}.${format}`; .normalize("NFKD")
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return `${slug || "export"}-${todayIso()}.${format}`;
} }
export function exportResponseHeaders(filename: string, format: "csv" | "xlsx"): HeadersInit { export function exportResponseHeaders(filename: string, format: "csv" | "xlsx"): HeadersInit {

View File

@@ -1,14 +1,53 @@
const dateFormatter = new Intl.DateTimeFormat("de-AT", { // This app stores dates as date-only strings ("YYYY-MM-DD") and timestamps as
// timestamptz, and is used from a single timezone. Every conversion here is
// pinned to Europe/Vienna rather than the runtime's zone: the server renders
// in UTC (Docker/Vercel) while the browser renders in Vienna, so an unpinned
// formatter produces a different day on each side — wrong dates for the user
// near midnight, and a React hydration mismatch.
const TIMEZONE = "Europe/Vienna";
const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
timeZone: TIMEZONE,
}); });
// en-CA formats as "YYYY-MM-DD", which is the shape the rest of the app and
// the database speak.
const isoFormatter = new Intl.DateTimeFormat("en-CA", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: TIMEZONE,
});
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
// Today in Vienna. Deliberately not new Date().toISOString().slice(0, 10):
// that is the *UTC* date, which for part of every day is already tomorrow
// relative to Austria.
export function todayIso(): string {
return isoFormatter.format(new Date());
}
// Normalizes either input to a "YYYY-MM-DD" string. A date-only string is
// returned as-is — parsing it into a Date first would anchor it to UTC
// midnight and shift it in any negative-offset zone.
export function toIsoDate(value: string | Date): string {
if (typeof value === "string") return DATE_ONLY.test(value) ? value : isoFormatter.format(new Date(value));
return isoFormatter.format(value);
}
export function fmtDate(date: string | Date | null | undefined): string { export function fmtDate(date: string | Date | null | undefined): string {
if (!date) return ""; if (!date) return "";
if (typeof date === "string" && DATE_ONLY.test(date)) {
const [year, month, day] = date.split("-");
return `${day}.${month}.${year}`;
}
const d = typeof date === "string" ? new Date(date) : date; const d = typeof date === "string" ? new Date(date) : date;
if (Number.isNaN(d.getTime())) return ""; if (Number.isNaN(d.getTime())) return "";
return dateFormatter.format(d); return dateTimeFormatter.format(d);
} }
export function initials(firstName: string, lastName: string): string { export function initials(firstName: string, lastName: string): string {
@@ -17,24 +56,37 @@ export function initials(firstName: string, lastName: string): string {
return `${a}${b}`; return `${a}${b}`;
} }
// "Dr. Max Mustermann, MSc MBA" — prefix titles precede the name, suffix
// titles follow after a comma, both space-joined in the stored order.
export function fmtFullName(
firstName: string,
lastName: string,
titlePrefix: string[] | null | undefined,
titleSuffix: string[] | null | undefined
): string {
const prefix = titlePrefix && titlePrefix.length > 0 ? `${titlePrefix.join(" ")} ` : "";
const suffix = titleSuffix && titleSuffix.length > 0 ? `, ${titleSuffix.join(" ")}` : "";
return `${prefix}${firstName} ${lastName}${suffix}`;
}
// Whole years between two ISO dates. Compares the "MM-DD" tails as strings,
// which is exact and needs no Date arithmetic at all.
export function yearsBetweenIso(from: string, to: string): number {
const years = Number(to.slice(0, 4)) - Number(from.slice(0, 4));
return to.slice(5) < from.slice(5) ? years - 1 : years;
}
export function fmtAge(birthDate: string | Date): number { export function fmtAge(birthDate: string | Date): number {
const d = typeof birthDate === "string" ? new Date(birthDate) : birthDate; return yearsBetweenIso(toIsoDate(birthDate), todayIso());
const today = new Date();
let age = today.getFullYear() - d.getFullYear();
const hasHadBirthdayThisYear =
today.getMonth() > d.getMonth() ||
(today.getMonth() === d.getMonth() && today.getDate() >= d.getDate());
if (!hasHadBirthdayThisYear) age -= 1;
return age;
} }
export function tenure(entryDate: string | Date, endDate?: string | Date | null): string { export function tenure(entryDate: string | Date, endDate?: string | Date | null): string {
const start = typeof entryDate === "string" ? new Date(entryDate) : entryDate; const start = toIsoDate(entryDate);
const end = endDate ? (typeof endDate === "string" ? new Date(endDate) : endDate) : new Date(); const end = endDate ? toIsoDate(endDate) : todayIso();
let years = end.getFullYear() - start.getFullYear(); let years = Number(end.slice(0, 4)) - Number(start.slice(0, 4));
let months = end.getMonth() - start.getMonth(); let months = Number(end.slice(5, 7)) - Number(start.slice(5, 7));
if (end.getDate() < start.getDate()) months -= 1; if (Number(end.slice(8, 10)) < Number(start.slice(8, 10))) months -= 1;
if (months < 0) { if (months < 0) {
years -= 1; years -= 1;
months += 12; months += 12;
@@ -47,8 +99,16 @@ export function tenure(entryDate: string | Date, endDate?: string | Date | null)
return yearPart || monthPart || "unter 1 Monat"; return yearPart || monthPart || "unter 1 Monat";
} }
export function daysBetween(a: string | Date, b: string | Date = new Date()): number { // Anchored at UTC midnight on both sides so the difference is a whole number
const start = typeof a === "string" ? new Date(a) : a; // of calendar days regardless of DST transitions in between.
const end = typeof b === "string" ? new Date(b) : b; export function daysBetweenIso(from: string, to: string = todayIso()): number {
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); const start = Date.parse(`${from}T00:00:00Z`);
const end = Date.parse(`${to}T00:00:00Z`);
return Math.round((end - start) / 86_400_000);
}
export function addDaysIso(iso: string, days: number): string {
const d = new Date(`${iso}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
} }

26
lib/notes.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { fetchAllRows } from "./supabase/query";
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")),
]);
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" };
});
}

212
lib/orgchart-data.ts Normal file
View File

@@ -0,0 +1,212 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import type { OrgEmployee } from "@/components/orgchart/types";
import { todayIso } from "./format";
import { deriveStatusAsOf } from "./reports";
import { fetchAllRows } from "./supabase/query";
import type { Database } from "./supabase/types";
// 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:
//
// 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
//
// See supabase/migrations/*_employee_assignment_history.sql for why the
// placement timeline is captured by a trigger rather than per-RPC.
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";
/** Change types that move someone in the org; the rest only affect status or contract. */
const PLACEMENT_CHANGES = ["transfer", "reorg", "promotion"] as const;
export type OrgAsOfResult = {
employees: OrgEmployee[];
/** How many placements were projected from not-yet-applied changes. */
projectedCount: number;
/** Earliest date the assignment history actually covers. */
historyStartsAt: string | null;
};
type EmployeeRow = {
id: string;
personnel_number: number;
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;
};
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 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> {
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")),
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")
)
: Promise.resolve([]),
]);
return resolveOrgSnapshot({ asOf, employees: allEmployees, assignments, teams, departments, pending });
}
// 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.
export function resolveOrgSnapshot({
asOf,
employees: allEmployees,
assignments,
teams,
departments,
pending,
}: {
asOf: string;
employees: EmployeeRow[];
assignments: AssignmentRow[];
teams: { id: string; department_id: string }[];
departments: { id: string; division_id: string }[];
pending: PendingRow[];
}): 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] : [];
})
);
// 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";
});
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,
};
});
// Project the future. Ordered by effective_date, so a later move wins.
const byId = new Map(resolved.map((r) => [r.employee.id, r]));
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;
}
// Second pass: a moved employee's manager follows from the *projected*
// org, not the one they left — and the lead of their new team may itself
// have moved in this same batch.
for (const r of resolved) {
if (moved.has(r.employee.id)) r.managerId = resolveManagerFor(r.placement, resolved.map((x) => ({ id: x.employee.id, placement: x.placement })));
}
// A manager who had not joined yet, or had already left, is not in this
// set — without re-rooting, their whole reporting line would silently
// vanish from the chart rather than showing up one level higher.
const presentIds = new Set(resolved.map((r) => r.employee.id));
const employees: OrgEmployee[] = resolved.map((r) => ({
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: r.managerId && presentIds.has(r.managerId) ? r.managerId : null,
team_id: r.placement.team_id,
division_id: r.placement.division_id,
is_lead: r.placement.is_lead,
org_level: r.placement.org_level,
}));
const historyStartsAt = assignments.reduce<string | null>(
(min, a) => (min === null || a.valid_from < min ? a.valid_from : min),
null
);
return { employees, projectedCount: moved.size, historyStartsAt };
}

View File

@@ -10,6 +10,7 @@ export type OpenPositionResolved = {
division_id: string; division_id: string;
is_lead: boolean; is_lead: boolean;
reports_to_employee_id: string | null; reports_to_employee_id: string | null;
valid_from: string;
created_at: string; created_at: string;
managerName: string | null; managerName: string | null;
orgLabel: string; orgLabel: string;
@@ -20,7 +21,7 @@ export async function loadOpenPositions(supabase: SupabaseClient<Database>): Pro
const orgMaps = await loadOrgMaps(supabase); const orgMaps = await loadOrgMaps(supabase);
const { data: positions } = await supabase const { data: positions } = await supabase
.from("positions") .from("positions")
.select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, created_at") .select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, valid_from, created_at")
.eq("status", "open") .eq("status", "open")
.order("created_at", { ascending: false }); .order("created_at", { ascending: false });

View File

@@ -1,5 +1,6 @@
import type { SupabaseClient } from "@supabase/supabase-js"; import type { SupabaseClient } from "@supabase/supabase-js";
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports"; import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
import { fetchAllRows } from "./supabase/query";
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types"; import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
// Shared by the Berichte page and /api/export/* so they can never drift on // Shared by the Berichte page and /api/export/* so they can never drift on
@@ -41,7 +42,19 @@ export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promis
} }
const SNAPSHOT_EMPLOYEE_COLUMNS = 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"; "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";
// 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;
}
// Bestand zum Stichtag: reconstructs each employee's status as of `asOf` // Bestand zum Stichtag: reconstructs each employee's status as of `asOf`
// (defaults to today) from entry/exit/Karenz dates — see deriveStatusAsOf. // (defaults to today) from entry/exit/Karenz dates — see deriveStatusAsOf.
@@ -49,14 +62,17 @@ const SNAPSHOT_EMPLOYEE_COLUMNS =
export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> { export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> {
const asOf = filters.asOf || todayIso(); const asOf = filters.asOf || todayIso();
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS); 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.division) query = query.eq("division_id", filters.division);
if (filters.location) query = query.eq("location_id", filters.location); if (filters.location) query = query.eq("location_id", filters.location);
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType); if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
return query;
}
const { data } = await query; const [data, dependentsCounts] = await Promise.all([fetchAllRows(snapshotQuery), loadDependentsCounts(supabase)]);
const withDerivedStatus: ReportEmployee[] = (data ?? []).map((e) => ({ const withDerivedStatus: ReportEmployee[] = data.map((e) => ({
id: e.id, id: e.id,
first_name: e.first_name, first_name: e.first_name,
last_name: e.last_name, last_name: e.last_name,
@@ -74,6 +90,14 @@ export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>,
paygrade: e.paygrade, paygrade: e.paygrade,
birth_date: e.birth_date, birth_date: e.birth_date,
gender: e.gender, gender: e.gender,
worker_type: e.worker_type,
collective_agreement: e.collective_agreement,
work_days: e.work_days,
is_betriebsrat: e.is_betriebsrat,
has_dienstwagen: e.has_dienstwagen,
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); const statuses = parseStatuses(filters.status);
@@ -93,19 +117,22 @@ export async function loadEventHistory(supabase: SupabaseClient<Database>, filte
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`; const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`;
const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`; const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
let historyQuery = supabase.from("employee_history").select("employee_id, event_date, event_type, description"); function historyQuery() {
if (from) historyQuery = historyQuery.gte("event_date", from); let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id");
if (to) historyQuery = historyQuery.lte("event_date", to); if (from) query = query.gte("event_date", from);
if (filters.eventType) historyQuery = historyQuery.eq("event_type", filters.eventType); if (to) query = query.lte("event_date", to);
if (filters.eventType) query = query.eq("event_type", filters.eventType);
return query;
}
const [{ data: history }, { data: employees }] = await Promise.all([ const [history, employees] = await Promise.all([
historyQuery, fetchAllRows(historyQuery),
supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id"), fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id").order("id")),
]); ]);
const employeeById = new Map((employees ?? []).map((e) => [e.id, e])); const employeeById = new Map(employees.map((e) => [e.id, e]));
const events: ReportEvent[] = []; const events: ReportEvent[] = [];
for (const h of history ?? []) { for (const h of history) {
const emp = employeeById.get(h.employee_id); const emp = employeeById.get(h.employee_id);
if (!emp) continue; if (!emp) continue;
if (filters.division && emp.division_id !== filters.division) continue; if (filters.division && emp.division_id !== filters.division) continue;

View File

@@ -1,8 +1,11 @@
import type { EmploymentStatus, HistoryEventType } from "./supabase/types"; import { todayIso, yearsBetweenIso } from "./format";
import type { EmploymentStatus, HistoryEventType, Weekday } from "./supabase/types";
export { todayIso };
// ── Bestand (point-in-time snapshot) ────────────────────────────── // ── Bestand (point-in-time snapshot) ──────────────────────────────
export type Measure = "headcount" | "fte" | "parttime_rate" | "avg_age" | "avg_tenure" | "female_share"; export type Measure = "headcount" | "fte" | "parttime_rate" | "avg_age" | "avg_tenure" | "female_share" | "avg_dependents";
export type GroupDimension = export type GroupDimension =
| "division" | "division"
@@ -14,7 +17,15 @@ export type GroupDimension =
| "contract_type" | "contract_type"
| "entry_year" | "entry_year"
| "source" | "source"
| "paygrade"; | "paygrade"
| "worker_type"
| "collective_agreement"
| "betriebsrat"
| "dienstwagen"
| "laterale_fuehrung"
| "c_level"
| "has_dependents"
| "weekday";
export const MEASURE_LABELS: Record<Measure, string> = { export const MEASURE_LABELS: Record<Measure, string> = {
headcount: "Headcount", headcount: "Headcount",
@@ -23,6 +34,7 @@ export const MEASURE_LABELS: Record<Measure, string> = {
avg_age: "Ø Alter", avg_age: "Ø Alter",
avg_tenure: "Ø Zugehörigkeit", avg_tenure: "Ø Zugehörigkeit",
female_share: "Frauenanteil", female_share: "Frauenanteil",
avg_dependents: "Ø Angehörige",
}; };
export const GROUP_LABELS: Record<GroupDimension, string> = { export const GROUP_LABELS: Record<GroupDimension, string> = {
@@ -36,9 +48,17 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
entry_year: "Eintrittsjahr", entry_year: "Eintrittsjahr",
source: "Intern/Extern", source: "Intern/Extern",
paygrade: "Paygrade", paygrade: "Paygrade",
worker_type: "Angestellte:r / Arbeiter:in",
collective_agreement: "Kollektivvertrag",
betriebsrat: "Betriebsrat",
dienstwagen: "Dienstwagen",
laterale_fuehrung: "Laterale Führung",
c_level: "C-Level",
has_dependents: "Hat Angehörige",
weekday: "Wochentag",
}; };
export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share"]; export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share", "avg_dependents"];
const SUM_MEASURES: Measure[] = ["headcount", "fte"]; const SUM_MEASURES: Measure[] = ["headcount", "fte"];
export const STATUS_OPTIONS: EmploymentStatus[] = ["Aktiv", "Karenz", "Geplant", "Ausgetreten"]; export const STATUS_OPTIONS: EmploymentStatus[] = ["Aktiv", "Karenz", "Geplant", "Ausgetreten"];
@@ -74,6 +94,14 @@ export type ReportEmployee = {
paygrade: string; paygrade: string;
birth_date: string; birth_date: string;
gender: string; gender: string;
worker_type: string;
collective_agreement: string;
work_days: Weekday[];
is_betriebsrat: boolean;
has_dienstwagen: boolean;
is_laterale_fuehrung: boolean;
is_c_level: boolean;
dependents_count: number;
}; };
export type OrgLookups = { export type OrgLookups = {
@@ -83,10 +111,6 @@ export type OrgLookups = {
locationName: Map<string, string>; locationName: Map<string, string>;
}; };
export function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
// Reconstructs status as of any date from the columns that actually carry a // Reconstructs status as of any date from the columns that actually carry a
// timeline (entry/exit/Karenz), rather than trusting `employees.status`, // timeline (entry/exit/Karenz), rather than trusting `employees.status`,
// which only ever reflects *today*. Division/team/location still reflect the // which only ever reflects *today*. Division/team/location still reflect the
@@ -104,18 +128,10 @@ export function deriveStatusAsOf(
return "Aktiv"; return "Aktiv";
} }
function ageAsOf(birthDate: string, asOf: string): number {
const d = new Date(birthDate);
const ref = new Date(asOf);
let age = ref.getFullYear() - d.getFullYear();
if (ref.getMonth() < d.getMonth() || (ref.getMonth() === d.getMonth() && ref.getDate() < d.getDate())) age -= 1;
return age;
}
function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: string): number { function tenureYearsAsOf(entryDate: string, exitDate: string | null, asOf: string): number {
const start = new Date(entryDate); const start = Date.parse(`${entryDate}T00:00:00Z`);
const end = exitDate && exitDate <= asOf ? new Date(exitDate) : new Date(asOf); const end = Date.parse(`${exitDate && exitDate <= asOf ? exitDate : asOf}T00:00:00Z`);
return Math.max(0, (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 365.25)); return Math.max(0, (end - start) / (1000 * 60 * 60 * 24 * 365.25));
} }
export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string { export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string {
@@ -135,16 +151,62 @@ export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: Org
case "contract_type": case "contract_type":
return e.contract_type; return e.contract_type;
case "entry_year": case "entry_year":
return String(new Date(e.entry_date).getFullYear()); return e.entry_date.slice(0, 4);
case "source": case "source":
return e.source; return e.source;
case "paygrade": case "paygrade":
return e.paygrade; return e.paygrade;
case "worker_type":
return e.worker_type;
case "collective_agreement":
return e.collective_agreement;
case "betriebsrat":
return e.is_betriebsrat ? "Ja" : "Nein";
case "dienstwagen":
return e.has_dienstwagen ? "Ja" : "Nein";
case "laterale_fuehrung":
return e.is_laterale_fuehrung ? "Ja" : "Nein";
case "c_level":
return e.is_c_level ? "Ja" : "Nein";
case "has_dependents":
return e.dependents_count > 0 ? "Ja" : "Nein";
case "weekday":
// Not a strict partition — see groupKeysFor, which aggregateReport
// actually uses. This single-key fallback only covers a direct
// groupKeyFor("weekday", ...) call from outside aggregateReport.
return e.work_days[0] ?? "";
default: default:
return "Unbekannt"; return "Unbekannt";
} }
} }
const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
function weekdayRank(key: string): number {
const i = WEEKDAY_ORDER.indexOf(key as Weekday);
return i === -1 ? WEEKDAY_ORDER.length : i;
}
function sortByWeekday<T extends { key: string }>(items: T[]): T[] {
return [...items].sort((a, b) => weekdayRank(a.key) - weekdayRank(b.key));
}
// Reused by ReportsPageClient (split legend) and the report export route
// (split columns) to render a `weekday` split chronologically rather than
// in first-encountered order; a no-op for every other dimension.
export function sortKeysForDimension(keys: string[], dim: GroupDimension): string[] {
return dim === "weekday" ? [...keys].sort((a, b) => weekdayRank(a) - weekdayRank(b)) : keys;
}
// Every dimension other than `weekday` is a strict single-key partition
// (delegates to groupKeyFor); `weekday` returns one key per work day, so an
// employee is counted in every day they work — deliberately not a
// partition, since that's the whole point of the dimension.
export function groupKeysFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string[] {
if (dim === "weekday") return e.work_days.length > 0 ? e.work_days : [""];
return [groupKeyFor(e, dim, lookups)];
}
export function measureValue(rows: ReportEmployee[], measure: Measure, asOf: string = todayIso()): number { export function measureValue(rows: ReportEmployee[], measure: Measure, asOf: string = todayIso()): number {
if (rows.length === 0) return 0; if (rows.length === 0) return 0;
switch (measure) { switch (measure) {
@@ -155,11 +217,13 @@ export function measureValue(rows: ReportEmployee[], measure: Measure, asOf: str
case "parttime_rate": case "parttime_rate":
return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100; return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100;
case "avg_age": case "avg_age":
return rows.reduce((s, e) => s + ageAsOf(e.birth_date, asOf), 0) / rows.length; return rows.reduce((s, e) => s + yearsBetweenIso(e.birth_date, asOf), 0) / rows.length;
case "avg_tenure": case "avg_tenure":
return rows.reduce((s, e) => s + tenureYearsAsOf(e.entry_date, e.exit_date, asOf), 0) / rows.length; return rows.reduce((s, e) => s + tenureYearsAsOf(e.entry_date, e.exit_date, asOf), 0) / rows.length;
case "female_share": case "female_share":
return (rows.filter((e) => e.gender === "w").length / rows.length) * 100; return (rows.filter((e) => e.gender === "w").length / rows.length) * 100;
case "avg_dependents":
return rows.reduce((s, e) => s + e.dependents_count, 0) / rows.length;
default: default:
return 0; return 0;
} }
@@ -179,10 +243,11 @@ export function aggregateReport(
): ReportRow[] { ): ReportRow[] {
const byGroup = new Map<string, ReportEmployee[]>(); const byGroup = new Map<string, ReportEmployee[]>();
for (const e of employees) { for (const e of employees) {
const key = groupKeyFor(e, group, lookups); for (const key of groupKeysFor(e, group, lookups)) {
if (!byGroup.has(key)) byGroup.set(key, []); if (!byGroup.has(key)) byGroup.set(key, []);
byGroup.get(key)!.push(e); byGroup.get(key)!.push(e);
} }
}
const rows: ReportRow[] = []; const rows: ReportRow[] = [];
for (const [key, rowsForGroup] of byGroup) { for (const [key, rowsForGroup] of byGroup) {
@@ -198,19 +263,21 @@ export function aggregateReport(
if (split) { if (split) {
const bySplit = new Map<string, ReportEmployee[]>(); const bySplit = new Map<string, ReportEmployee[]>();
for (const e of rowsForGroup) { for (const e of rowsForGroup) {
const sKey = groupKeyFor(e, split, lookups); for (const sKey of groupKeysFor(e, split, lookups)) {
if (!bySplit.has(sKey)) bySplit.set(sKey, []); if (!bySplit.has(sKey)) bySplit.set(sKey, []);
bySplit.get(sKey)!.push(e); bySplit.get(sKey)!.push(e);
} }
row.split = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({ }
const splitRows = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({
key: sKey, key: sKey,
value: measureValue(sRows, measure, asOf), value: measureValue(sRows, measure, asOf),
count: sRows.length, count: sRows.length,
})); }));
row.split = split === "weekday" ? sortByWeekday(splitRows) : splitRows;
} }
rows.push(row); rows.push(row);
} }
return rows.sort((a, b) => b.value - a.value); return group === "weekday" ? sortByWeekday(rows) : rows.sort((a, b) => b.value - a.value);
} }
export function sumValues(rows: { value: number }[]): number { export function sumValues(rows: { value: number }[]): number {
@@ -231,6 +298,9 @@ export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimen
{ name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" }, { name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" },
{ name: "Headcount nach Paygrade", measure: "headcount", group: "paygrade" }, { name: "Headcount nach Paygrade", measure: "headcount", group: "paygrade" },
{ name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" }, { name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" },
{ name: "Headcount nach Wochentag", measure: "headcount", group: "weekday" },
{ name: "Headcount nach C-Level", measure: "headcount", group: "c_level" },
{ name: "Ø Angehörige nach Bereich", measure: "avg_dependents", group: "division" },
]; ];
// ── Ereignisse (events over a period) ───────────────────────────── // ── Ereignisse (events over a period) ─────────────────────────────
@@ -295,7 +365,7 @@ function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: Org
case "location": case "location":
return lookups.locationName.get(e.location_id) ?? "Unbekannt"; return lookups.locationName.get(e.location_id) ?? "Unbekannt";
case "event_year": case "event_year":
return String(new Date(e.event_date).getFullYear()); return e.event_date.slice(0, 4);
default: default:
return "Unbekannt"; return "Unbekannt";
} }
@@ -347,3 +417,64 @@ export const EVENT_REPORT_PRESETS: { name: string; group: EventGroupDimension; s
{ name: "Austritte nach Abteilung", group: "department", eventType: "Austritt" }, { name: "Austritte nach Abteilung", group: "department", eventType: "Austritt" },
{ name: "Beförderungen nach Bereich", group: "division", eventType: "Beförderung" }, { name: "Beförderungen nach Bereich", group: "division", eventType: "Beförderung" },
]; ];
// ── Query-string parsing ──────────────────────────────────────────
// The Berichte page and every /api/export/* route read the same handful of
// dimension/measure names out of a URL the user fully controls. These used
// to be unchecked `as` casts, which let an unknown value through as a real
// enum value: it reached GROUP_LABELS[group] as undefined (a literal
// "undefined" column header in the export) and was interpolated into the
// download filename, i.e. into a Content-Disposition header. Parsing against
// the label maps — the same objects that define the legal values — keeps the
// two in step by construction.
function parseKeyOf<T extends string>(labels: Record<T, string>, value: string | null | undefined, fallback: T): T {
return value && Object.hasOwn(labels, value) ? (value as T) : fallback;
}
export function parseMode(value: string | null | undefined): "snapshot" | "events" {
return value === "events" ? "events" : "snapshot";
}
export function parseMeasure(value: string | null | undefined): Measure {
return parseKeyOf(MEASURE_LABELS, value, "headcount");
}
export function parseGroupDimension(value: string | null | undefined, fallback: GroupDimension = "division"): GroupDimension {
return parseKeyOf(GROUP_LABELS, value, fallback);
}
export function parseEventGroupDimension(
value: string | null | undefined,
fallback: EventGroupDimension = "event_type"
): EventGroupDimension {
return parseKeyOf(EVENT_GROUP_LABELS, value, fallback);
}
// Unlike the dimensions above, "no split" and "all event types" are legal —
// hence null rather than a fallback value for an unrecognized input.
export function parseSplitDimension(value: string | null | undefined): GroupDimension | null {
return value && Object.hasOwn(GROUP_LABELS, value) ? (value as GroupDimension) : null;
}
export function parseEventSplitDimension(value: string | null | undefined): EventGroupDimension | null {
return value && Object.hasOwn(EVENT_GROUP_LABELS, value) ? (value as EventGroupDimension) : null;
}
export function parseEventType(value: string | null | undefined): HistoryEventType | null {
return value && Object.hasOwn(EVENT_TYPE_LABELS, value) ? (value as HistoryEventType) : null;
}
// Rejects anything that is not a real calendar date, so a Stichtag from the
// URL can never reach a date comparison (or a column header) as free text.
export function parseIsoDateParam(value: string | null | undefined): string | undefined {
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined;
const d = new Date(`${value}T00:00:00Z`);
return Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== value ? undefined : value;
}
// from/to additionally accept the EVENT_DATE_OPEN sentinel ("this side of
// the interval is intentionally unbounded"), which is not a date.
export function parseEventDateParam(value: string | null | undefined): string | undefined {
return value === EVENT_DATE_OPEN ? EVENT_DATE_OPEN : parseIsoDateParam(value);
}

View File

@@ -7,9 +7,17 @@ import type { Database } from "./types";
// "server-only" import makes an accidental client-side import a build error // "server-only" import makes an accidental client-side import a build error
// instead of a runtime one. // instead of a runtime one.
export function createAdminClient() { export function createAdminClient() {
return createSupabaseClient<Database>( const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
process.env.NEXT_PUBLIC_SUPABASE_URL!, const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { autoRefreshToken: false, persistSession: false } } if (!supabaseUrl) {
); throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
}
if (!serviceRoleKey) {
throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY");
}
return createSupabaseClient<Database>(supabaseUrl, serviceRoleKey, {
auth: { autoRefreshToken: false, persistSession: false },
});
} }

21
lib/supabase/auth.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { NextResponse } from "next/server";
import type { Database } from "./types";
// Route Handlers under /api/export/* are outside the App Router layout tree,
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
// re-establish that the caller is an active HR user itself. RLS is still the
// real boundary (an unauthorized session simply reads nothing); this exists
// so those routes answer 401/403 instead of handing back an empty workbook.
export async function requireHrUser(supabase: SupabaseClient<Database>): Promise<NextResponse | null> {
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
if (profile?.role !== "hr" || profile.is_active !== true) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
}
return null;
}

View File

@@ -3,9 +3,17 @@ import type { Database } from "./types";
// For use in Client Components that need interactivity (filters, live // For use in Client Components that need interactivity (filters, live
// hints, etc). Server Components/Actions should use lib/supabase/server.ts. // 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() { export function createClient() {
return createBrowserClient<Database>( const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
process.env.NEXT_PUBLIC_SUPABASE_URL!, const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
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);
} }

View File

@@ -7,3 +7,27 @@
export function sanitizeIlikeTerm(term: string): string { export function sanitizeIlikeTerm(term: string): string {
return term.replace(/[,()]/g, ""); 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;
}

View File

@@ -1,16 +1,26 @@
import "server-only";
import { createServerClient } from "@supabase/ssr"; import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import type { Database } from "./types"; import type { Database } from "./types";
// For use in Server Components and Server Actions. Respects the signed-in // 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. // 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() { 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(); const cookieStore = await cookies();
return createServerClient<Database>( return createServerClient<Database>(supabaseUrl, supabaseAnonKey, {
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: { cookies: {
getAll() { getAll() {
return cookieStore.getAll(); return cookieStore.getAll();
@@ -26,6 +36,5 @@ export async function createClient() {
} }
}, },
}, },
} });
);
} }

View File

@@ -9,6 +9,11 @@ export type ContractType = "unbefristet" | "befristet";
export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F"; export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F";
export type SourceType = "Intern" | "Extern"; export type SourceType = "Intern" | "Extern";
export type GenderType = "m" | "w"; 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";
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 // Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a
// union (not a string literal) so a future hr_admin/hr_user split, if ever // union (not a string literal) so a future hr_admin/hr_user split, if ever
// technically required, is a type-level addition, not a rewrite. // technically required, is a type-level addition, not a rewrite.
@@ -107,6 +112,8 @@ export type Database = {
sv_nummer: string | null; sv_nummer: string | null;
nationality: string; nationality: string;
address: string | null; address: string | null;
postal_code: string | null;
city: string | null;
address_country: string | null; address_country: string | null;
email: string; email: string;
phone: string | null; phone: string | null;
@@ -132,6 +139,15 @@ export type Database = {
karenz_start_date: string | null; karenz_start_date: string | null;
karenz_return_date: string | null; karenz_return_date: string | null;
avatar_color: string | null; avatar_color: string | null;
worker_type: WorkerType;
collective_agreement: CollectiveAgreement;
work_days: Weekday[];
is_betriebsrat: boolean;
has_dienstwagen: boolean;
is_laterale_fuehrung: boolean;
is_c_level: boolean;
title_prefix: string[];
title_suffix: string[];
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -144,6 +160,8 @@ export type Database = {
sv_nummer?: string | null; sv_nummer?: string | null;
nationality?: string; nationality?: string;
address?: string | null; address?: string | null;
postal_code?: string | null;
city?: string | null;
address_country?: string | null; address_country?: string | null;
email: string; email: string;
phone?: string | null; phone?: string | null;
@@ -167,6 +185,15 @@ export type Database = {
karenz_start_date?: string | null; karenz_start_date?: string | null;
karenz_return_date?: string | null; karenz_return_date?: string | null;
avatar_color?: string | null; avatar_color?: string | null;
worker_type?: WorkerType;
collective_agreement?: CollectiveAgreement;
work_days?: Weekday[];
is_betriebsrat?: boolean;
has_dienstwagen?: boolean;
is_laterale_fuehrung?: boolean;
is_c_level?: boolean;
title_prefix?: string[];
title_suffix?: string[];
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
}; };
@@ -193,6 +220,58 @@ export type Database = {
}; };
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>; Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
}; };
employee_dependents: NoRelationships & {
Row: {
id: string;
employee_id: string;
first_name: string;
last_name: string;
relationship: RelationshipType;
sv_nummer: string | null;
birth_date: string;
created_at: string;
};
Insert: {
id?: string;
employee_id: string;
first_name: string;
last_name: string;
relationship: RelationshipType;
sv_nummer?: string | null;
birth_date: string;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employee_dependents"]["Insert"]>;
};
employee_notes: NoRelationships & {
Row: {
id: string;
employee_id: string;
author_user_id: string | null;
author_name: string;
category: NoteCategory;
note_text: string;
due_date: string | null;
done: boolean;
done_at: string | null;
done_by: string | null;
created_at: string;
};
Insert: {
id?: string;
employee_id: string;
author_user_id?: string | null;
author_name: string;
category?: NoteCategory;
note_text: string;
due_date?: string | null;
done?: boolean;
done_at?: string | null;
done_by?: string | null;
created_at?: string;
};
Update: Partial<Database["public"]["Tables"]["employee_notes"]["Insert"]>;
};
positions: NoRelationships & { positions: NoRelationships & {
Row: { Row: {
id: string; id: string;
@@ -203,6 +282,7 @@ export type Database = {
is_lead: boolean; is_lead: boolean;
reports_to_employee_id: string | null; reports_to_employee_id: string | null;
status: PositionStatus; status: PositionStatus;
valid_from: string;
created_at: string; created_at: string;
filled_at: string | null; filled_at: string | null;
filled_by_employee_id: string | null; filled_by_employee_id: string | null;
@@ -216,6 +296,7 @@ export type Database = {
is_lead?: boolean; is_lead?: boolean;
reports_to_employee_id?: string | null; reports_to_employee_id?: string | null;
status?: PositionStatus; status?: PositionStatus;
valid_from?: string;
created_at?: string; created_at?: string;
filled_at?: string | null; filled_at?: string | null;
filled_by_employee_id?: string | null; filled_by_employee_id?: string | null;
@@ -310,6 +391,25 @@ export type Database = {
}; };
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>; 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 & {
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;
valid_from: string;
valid_to: string | null;
created_at: string;
};
Insert: never;
Update: never;
};
}; };
Views: Record<string, never>; Views: Record<string, never>;
Functions: { Functions: {
@@ -322,7 +422,12 @@ export type Database = {
record_karenz_return: { Args: { payload: Record<string, unknown> }; Returns: void }; record_karenz_return: { Args: { payload: Record<string, unknown> }; Returns: void };
change_employee_data: { Args: { payload: Record<string, unknown> }; Returns: void }; change_employee_data: { Args: { payload: Record<string, unknown> }; Returns: void };
rehire_employee: { Args: { payload: Record<string, unknown> }; Returns: void }; rehire_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
add_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
delete_employee_dependent: { Args: { payload: Record<string, unknown> }; Returns: void };
add_employee_note: { Args: { payload: Record<string, unknown> }; Returns: string };
complete_employee_note: { Args: { payload: Record<string, unknown> }; Returns: void };
create_position: { Args: { payload: Record<string, unknown> }; Returns: string }; create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
delete_position: { Args: { payload: Record<string, unknown> }; Returns: void };
staff_position_internally: { Args: { payload: Record<string, unknown> }; Returns: void }; staff_position_internally: { Args: { payload: Record<string, unknown> }; Returns: void };
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string }; apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void }; undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };

8
lib/titles.ts Normal file
View File

@@ -0,0 +1,8 @@
// Standard Austrian academic/professional titles (Bundeskanzleramt/ELDA
// convention): vorangestellte akademische Grade (prefix, precede the name)
// and nachgestellte akademische Grade (suffix, mostly Bologna-system
// Bachelor's/Master's degrees, follow the name after a comma). A person can
// hold several of either.
export const TITLE_PREFIXES: string[] = ["Dr.", "DDr.", "Dipl.-Ing.", "Ing.", "Mag.", "Mag. (FH)", "MMag.", "Dkfm.", "Priv.-Doz.", "Prof."];
export const TITLE_SUFFIXES: string[] = ["BA", "BSc", "BEd", "BBA", "LLB", "MA", "MSc", "MBA", "MEd", "LLM", "PhD", "MBL"];

View File

@@ -1,6 +1,9 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// Emits a self-contained .next/standalone server (only the deps actually
// used at runtime, no full node_modules) - what the Dockerfile copies in.
output: "standalone",
// Baseline security headers (clickjacking, MIME-sniffing, referrer leakage, // Baseline security headers (clickjacking, MIME-sniffing, referrer leakage,
// browser feature access). No Content-Security-Policy yet: this app has no // browser feature access). No Content-Security-Policy yet: this app has no
// inventory of its script/style/connect sources, and shipping a guessed // inventory of its script/style/connect sources, and shipping a guessed

263
package-lock.json generated
View File

@@ -8,8 +8,10 @@
"name": "alpenwerk-hr", "name": "alpenwerk-hr",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@supabase/ssr": "^0.12.1", "@supabase/ssr": "^0.12.1",
"@supabase/supabase-js": "^2.110.5", "@supabase/supabase-js": "^2.110.5",
"@xyflow/react": "^12.11.2",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"lucide-react": "^1.24.0", "lucide-react": "^1.24.0",
"next": "16.2.10", "next": "16.2.10",
@@ -295,6 +297,21 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@dagrejs/dagre": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-3.0.0.tgz",
"integrity": "sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==",
"license": "MIT",
"dependencies": {
"@dagrejs/graphlib": "4.0.1"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-4.0.1.tgz",
"integrity": "sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==",
"license": "MIT"
},
"node_modules/@ecies/ciphers": { "node_modules/@ecies/ciphers": {
"version": "0.2.6", "version": "0.2.6",
"resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz", "resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz",
@@ -2372,6 +2389,55 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -2414,7 +2480,7 @@
"version": "19.2.17", "version": "19.2.17",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -2424,7 +2490,7 @@
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
@@ -3171,6 +3237,48 @@
"url": "https://opencollective.com/vitest" "url": "https://opencollective.com/vitest"
} }
}, },
"node_modules/@xyflow/react": {
"version": "12.11.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz",
"integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.79",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"@types/react": ">=17",
"@types/react-dom": ">=17",
"react": ">=17",
"react-dom": ">=17"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.79",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz",
"integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.17.0", "version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@@ -3877,6 +3985,12 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "url": "https://github.com/chalk/chalk?sponsor=1"
} }
}, },
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/client-only": { "node_modules/client-only": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -3994,9 +4108,114 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -7424,7 +7643,6 @@
"version": "8.5.19", "version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -8823,6 +9041,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -9254,6 +9481,34 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
} }
} }
} }

View File

@@ -15,8 +15,10 @@
"check": "npm run lint && npm run typecheck && npm run test && npm run build" "check": "npm run lint && npm run typecheck && npm run test && npm run build"
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@supabase/ssr": "^0.12.1", "@supabase/ssr": "^0.12.1",
"@supabase/supabase-js": "^2.110.5", "@supabase/supabase-js": "^2.110.5",
"@xyflow/react": "^12.11.2",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"lucide-react": "^1.24.0", "lucide-react": "^1.24.0",
"next": "16.2.10", "next": "16.2.10",

View File

@@ -52,7 +52,7 @@ export async function proxy(request: NextRequest) {
if (isLoginRoute) return response; if (isLoginRoute) return response;
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
url.pathname = "/login"; url.pathname = "/login";
url.searchParams.set("error", "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in."); url.searchParams.set("error", "no_hr_access");
return NextResponse.redirect(url); return NextResponse.redirect(url);
} }

View File

@@ -0,0 +1,202 @@
-- Split the combined "Straße Nr, PLZ Ort" address string into three fields.
--
-- employees.address now holds only Straße + Hausnummer (still a single
-- free-text line); postal_code and city are their own columns so the UI
-- can offer them as separate inputs and reports/exports can filter or
-- group by them independently.
alter table employees add column if not exists postal_code text;
alter table employees add column if not exists city text;
-- Best-effort backfill for existing rows seeded in the old combined format
-- (supabase/seed.ts previously wrote "Straße Nr, PLZ Ort"). Only rewrites
-- rows that actually match that exact "<street>, <postal> <city>" shape;
-- anything else (already-split rows, hand-edited free text, no comma) is
-- left untouched for HR to fill in via "Daten ändern".
update employees
set
postal_code = trim(split_part(split_part(address, ', ', 2), ' ', 1)),
city = trim(substring(split_part(address, ', ', 2) from '\S+\s+(.*)$')),
address = trim(split_part(address, ', ', 1))
where address ~ '^[^,]+,\s*\d{3,6}\s*[[:alpha:]].*$';
-- ── change_employee_data: recognize postal_code/city as person fields ──
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
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 text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_immediate boolean;
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;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'postal_code' and coalesce(v_person->>'postal_code','') <> coalesce(v_old.postal_code,'') then v_person_changes := array_append(v_person_changes, 'Postleitzahl'); end if;
if v_person ? 'city' and coalesce(v_person->>'city','') <> coalesce(v_old.city,'') then v_person_changes := array_append(v_person_changes, 'Ort'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); 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),
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
where id = v_employee_id;
elsif array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 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 array_length(v_person_changes, 1) > 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: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 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: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── apply_due_pending_changes: mirror the same postal_code/city handling
-- in the deferred contract_change branch ──
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
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),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date else contract_end_date end
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,183 @@
-- Position validity window + delete capability.
--
-- 1. Positions had no "gültig ab" (valid_from) date — nothing recorded
-- since when a position is actually meant to be active, so a position
-- created today for a future need could immediately be staffed/hired
-- into.
-- 2. There was no way to remove a position again once created (no UI, no
-- Server Action, no RPC) — a mis-created or no-longer-needed open
-- requisition was stuck forever.
-- 3. Neither staff_position_internally nor hire_employee checked the
-- position's validity window before assigning an employee to it.
alter table positions add column if not exists valid_from date not null default current_date;
-- ── Position ausschreiben: now records valid_from ────────────────
create or replace function create_position(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_superior_id uuid := (payload->>'superior_employee_id')::uuid;
v_is_lead boolean := coalesce((payload->>'is_lead')::boolean, false);
v_team_id uuid;
v_valid_from date := coalesce(nullif(payload->>'valid_from', '')::date, current_date);
begin
perform require_hr_admin();
if v_is_lead then
v_team_id := (payload->>'team_id')::uuid;
else
select team_id into v_team_id from employees where id = v_superior_id;
end if;
insert into positions (title, team_id, is_lead, reports_to_employee_id, valid_from)
values (payload->>'title', v_team_id, v_is_lead, v_superior_id, v_valid_from)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Ausschreibung', payload->>'title', 'Position ausgeschrieben, gültig ab ' || v_valid_from);
return v_id;
end;
$$;
-- ── Position löschen ───────────────────────────────────────────────
-- Only open (unfilled) positions can be deleted — a filled position is
-- already tied to an employee's history/audit trail and to whoever holds it.
create or replace function delete_position(payload jsonb)
returns void language plpgsql as $$
declare
v_position record;
begin
perform require_hr_admin();
select * into v_position from positions where id = (payload->>'position_id')::uuid;
if not found then
raise exception 'Position nicht gefunden.';
end if;
if v_position.status <> 'open' then
raise exception 'Nur offene Positionen können gelöscht werden.';
end if;
delete from positions where id = v_position.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
values (auth.uid(), current_actor_name(), 'Position gelöscht', v_position.title, 'Position ' || v_position.position_number || ' gelöscht');
end;
$$;
-- ── Intern besetzen: reject if the position isn't valid yet ─────────
create or replace function staff_position_internally(payload jsonb)
returns void language plpgsql as $$
declare
v_position record;
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_manager uuid;
v_name text;
begin
perform require_hr_admin();
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
if v_position.valid_from > current_date then
raise exception 'Die Position ist erst ab % gültig.', to_char(v_position.valid_from, 'DD.MM.YYYY');
end if;
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
v_manager := resolve_manager_for(v_position.team_id, v_position.is_lead, v_position.division_id);
update employees set
team_id = v_position.team_id,
job_title = v_position.title,
source = 'Intern',
is_lead = case when v_position.is_lead then true else is_lead end,
org_level = case when v_position.is_lead then 2 else org_level end,
manager_id = v_manager
where id = v_employee_id;
if v_position.is_lead then
update employees set manager_id = v_employee_id
where team_id = v_position.team_id and id <> v_employee_id and is_lead = false and status <> 'Ausgetreten';
end if;
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_employee_id
where id = v_position.id;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_employee_id, current_date, 'Versetzung', 'Interne Besetzung: ' || v_position.title);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Interne Besetzung', v_name, v_employee_id, v_position.title);
end;
$$;
-- ── Neueinstellung: reject if entry_date precedes the position's validity ──
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
if (payload->>'entry_date')::date < v_position.valid_from then
raise exception 'Das Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn der Position (%) liegen.', to_char(v_position.valid_from, 'DD.MM.YYYY');
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, contract_type, contract_end_date,
paygrade, source, status, entry_date
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
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 ' || (payload->>'entry_date'));
return v_id;
end;
$$;

View File

@@ -0,0 +1,309 @@
-- "Rolle & Anstellung" — role/employment classification that HR tracked
-- outside the system until now: worker category (Angestellte:r vs.
-- Arbeiter:in), collective agreement, contracted work days, and a handful
-- of eligibility/reporting flags (Betriebsrat, Dienstwagen, laterale
-- Führung, C-Level).
create type worker_type as enum ('Angestellte:r', 'Arbeiter:in');
create type collective_agreement as enum ('Handel', 'Süßwaren');
alter table employees add column if not exists worker_type worker_type not null default 'Angestellte:r';
alter table employees add column if not exists collective_agreement collective_agreement not null default 'Handel';
alter table employees add column if not exists work_days text[] not null default '{Mo,Di,Mi,Do,Fr}';
alter table employees add column if not exists is_betriebsrat boolean not null default false;
alter table employees add column if not exists has_dienstwagen boolean not null default false;
alter table employees add column if not exists is_laterale_fuehrung boolean not null default false;
alter table employees add column if not exists is_c_level boolean not null default false;
alter table employees add constraint chk_work_days_valid check (
work_days <@ array['Mo','Di','Mi','Do','Fr','Sa','So']::text[] and cardinality(work_days) > 0
);
-- ── Neueinstellung: accepts the new role/employment fields ─────────────
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
if (payload->>'entry_date')::date < v_position.valid_from then
raise exception 'Das Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn der Position (%) liegen.', to_char(v_position.valid_from, 'DD.MM.YYYY');
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, contract_type, contract_end_date,
paygrade, source, status, entry_date,
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'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date,
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Handel'),
case when payload ? 'work_days' then coalesce((select array_agg(elem) from jsonb_array_elements_text(payload->'work_days') elem), '{}') else '{Mo,Di,Mi,Do,Fr}' end,
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;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
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 ' || (payload->>'entry_date'));
return v_id;
end;
$$;
-- ── Daten ändern: recognize a "role" section alongside person/contract ──
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
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 text[] := '{}';
v_contract_changes text[] := '{}';
v_person jsonb := payload->'person';
v_contract jsonb := payload->'contract';
v_role jsonb := payload->'role';
v_immediate boolean;
v_new_work_days 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;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'postal_code' and coalesce(v_person->>'postal_code','') <> coalesce(v_old.postal_code,'') then v_person_changes := array_append(v_person_changes, 'Postleitzahl'); end if;
if v_person ? 'city' and coalesce(v_person->>'city','') <> coalesce(v_old.city,'') then v_person_changes := array_append(v_person_changes, 'Ort'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
if v_role ? 'worker_type' and (v_role->>'worker_type') <> v_old.worker_type::text then v_contract_changes := array_append(v_contract_changes, 'Angestellte:r/Arbeiter:in'); end if;
if v_role ? 'collective_agreement' and (v_role->>'collective_agreement') <> v_old.collective_agreement::text then v_contract_changes := array_append(v_contract_changes, 'Kollektivvertrag'); 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), '{}');
if v_new_work_days is distinct from v_old.work_days then v_contract_changes := array_append(v_contract_changes, 'Arbeitstage'); end if;
end if;
if v_role ? 'is_betriebsrat' and (v_role->>'is_betriebsrat')::boolean <> v_old.is_betriebsrat then v_contract_changes := array_append(v_contract_changes, 'Betriebsrat'); end if;
if v_role ? 'has_dienstwagen' and (v_role->>'has_dienstwagen')::boolean <> v_old.has_dienstwagen then v_contract_changes := array_append(v_contract_changes, 'Dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' and (v_role->>'is_laterale_fuehrung')::boolean <> v_old.is_laterale_fuehrung then v_contract_changes := array_append(v_contract_changes, 'Laterale Führung'); end if;
if v_role ? 'is_c_level' and (v_role->>'is_c_level')::boolean <> v_old.is_c_level then v_contract_changes := array_append(v_contract_changes, '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),
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 array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 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 array_length(v_person_changes, 1) > 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: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 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: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── apply_due_pending_changes: mirror the same role-field handling in the
-- deferred contract_change branch ──
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
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),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_rec.payload->'role'->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_rec.payload->'role'->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_rec.payload->'role' ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'role'->'work_days') elem), '{}') else work_days end,
is_betriebsrat = coalesce((v_rec.payload->'role'->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_rec.payload->'role'->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_rec.payload->'role'->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_rec.payload->'role'->>'is_c_level')::boolean, is_c_level)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,78 @@
-- "Angehörige" — dependents/family members tracked per employee (spouse,
-- children, ...), relevant for payroll/insurance. One employee can have
-- several. Editing is add/remove only — fix a mistaken entry by deleting
-- and re-adding it, same as "Position löschen" — no in-place edit RPC.
create table employee_dependents (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
first_name text not null,
last_name text not null,
relationship text not null check (relationship in ('Ehepartner:in', 'Lebenspartner:in', 'Kind', 'Sonstige')),
sv_nummer text,
birth_date date not null,
created_at timestamptz not null default now()
);
create index on employee_dependents (employee_id);
-- RLS only narrows what an already-GRANTed role may do; the "grant all ...
-- to anon, authenticated, service_role" default privilege (see
-- 20260714120500_default_grants.sql) already covers this new table.
alter table employee_dependents enable row level security;
create policy "employee_dependents_hr_all" on employee_dependents for all
using (is_hr_user()) with check (is_hr_user());
-- ── Angehörige:n hinzufügen ───────────────────────────────────────────
create or replace function add_employee_dependent(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_employee_name text;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_employee_name from employees where id = v_employee_id;
if not found then
raise exception 'Mitarbeiter:in nicht gefunden.';
end if;
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (
v_employee_id, payload->>'first_name', payload->>'last_name', payload->>'relationship',
nullif(payload->>'sv_nummer', ''), (payload->>'birth_date')::date
)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'Angehörige:r hinzugefügt', v_employee_name, v_employee_id,
(payload->>'first_name') || ' ' || (payload->>'last_name') || ' (' || (payload->>'relationship') || ')'
);
return v_id;
end;
$$;
-- ── Angehörige:n entfernen ────────────────────────────────────────────
create or replace function delete_employee_dependent(payload jsonb)
returns void language plpgsql as $$
declare
v_dep employee_dependents%rowtype;
v_employee_name text;
begin
perform require_hr_admin();
select * into v_dep from employee_dependents where id = (payload->>'dependent_id')::uuid;
if not found then
raise exception 'Angehörige:r nicht gefunden.';
end if;
select first_name || ' ' || last_name into v_employee_name from employees where id = v_dep.employee_id;
delete from employee_dependents where id = v_dep.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'Angehörige:r entfernt', v_employee_name, v_dep.employee_id,
v_dep.first_name || ' ' || v_dep.last_name || ' (' || v_dep.relationship || ')'
);
end;
$$;

View File

@@ -0,0 +1,321 @@
-- Vor- und nachgestellte akademische Titel (Bundeskanzleramt/ELDA-Standardliste,
-- see lib/titles.ts) — e.g. "Dr. Max Mustermann, MSc". A person can hold
-- several of either, so both are text[] like work_days.
alter table employees add column if not exists title_prefix text[] not null default '{}';
alter table employees add column if not exists title_suffix text[] not null default '{}';
alter table employees add constraint chk_title_prefix_valid check (
title_prefix <@ array['Dr.','DDr.','Dipl.-Ing.','Ing.','Mag.','Mag. (FH)','MMag.','Dkfm.','Priv.-Doz.','Prof.']::text[]
);
alter table employees add constraint chk_title_suffix_valid check (
title_suffix <@ array['BA','BSc','BEd','BBA','LLB','MA','MSc','MBA','MEd','LLM','PhD','MBL']::text[]
);
-- ── Neueinstellung: accepts title_prefix/title_suffix ───────────────────
create or replace function hire_employee(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_team_id uuid;
v_division_id uuid;
v_position record;
v_job_title text;
v_email text;
v_manager uuid;
begin
perform require_hr_admin();
if payload->>'position_id' is not null then
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
if not found then
raise exception 'Position ist nicht mehr offen.';
end if;
if (payload->>'entry_date')::date < v_position.valid_from then
raise exception 'Das Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn der Position (%) liegen.', to_char(v_position.valid_from, 'DD.MM.YYYY');
end if;
v_team_id := v_position.team_id;
v_division_id := v_position.division_id;
v_job_title := coalesce(payload->>'job_title', v_position.title);
else
v_team_id := (payload->>'team_id')::uuid;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
v_job_title := payload->>'job_title';
end if;
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
insert into employees (
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
employment_type, weekly_hours, contract_type, contract_end_date,
paygrade, source, status, entry_date,
worker_type, collective_agreement, work_days,
is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level,
title_prefix, title_suffix
) values (
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
v_email, payload->>'phone',
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
v_manager, 3, false,
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 (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
(payload->>'entry_date')::date,
coalesce((payload->>'worker_type')::worker_type, 'Angestellte:r'),
coalesce((payload->>'collective_agreement')::collective_agreement, 'Handel'),
case when payload ? 'work_days' then coalesce((select array_agg(elem) from jsonb_array_elements_text(payload->'work_days') elem), '{}') else '{Mo,Di,Mi,Do,Fr}' end,
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),
case when payload ? 'title_prefix' then coalesce((select array_agg(elem) from jsonb_array_elements_text(payload->'title_prefix') elem), '{}') else '{}' end,
case when payload ? 'title_suffix' then coalesce((select array_agg(elem) from jsonb_array_elements_text(payload->'title_suffix') elem), '{}') else '{}' end
) returning id into v_id;
if payload->>'position_id' is not null then
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
where id = (payload->>'position_id')::uuid;
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
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 ' || (payload->>'entry_date'));
return v_id;
end;
$$;
-- ── Daten ändern: person section recognizes title_prefix/title_suffix ──
create or replace function change_employee_data(payload jsonb)
returns void language plpgsql as $$
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 text[] := '{}';
v_contract_changes text[] := '{}';
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;
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
if v_person ? 'postal_code' and coalesce(v_person->>'postal_code','') <> coalesce(v_old.postal_code,'') then v_person_changes := array_append(v_person_changes, 'Postleitzahl'); end if;
if v_person ? 'city' and coalesce(v_person->>'city','') <> coalesce(v_old.city,'') then v_person_changes := array_append(v_person_changes, 'Ort'); end if;
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); 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), '{}');
if v_new_title_prefix is distinct from v_old.title_prefix then v_person_changes := array_append(v_person_changes, 'Titel (vorangestellt)'); end if;
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), '{}');
if v_new_title_suffix is distinct from v_old.title_suffix then v_person_changes := array_append(v_person_changes, 'Titel (nachgestellt)'); end if;
end if;
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
if v_role ? 'worker_type' and (v_role->>'worker_type') <> v_old.worker_type::text then v_contract_changes := array_append(v_contract_changes, 'Angestellte:r/Arbeiter:in'); end if;
if v_role ? 'collective_agreement' and (v_role->>'collective_agreement') <> v_old.collective_agreement::text then v_contract_changes := array_append(v_contract_changes, 'Kollektivvertrag'); 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), '{}');
if v_new_work_days is distinct from v_old.work_days then v_contract_changes := array_append(v_contract_changes, 'Arbeitstage'); end if;
end if;
if v_role ? 'is_betriebsrat' and (v_role->>'is_betriebsrat')::boolean <> v_old.is_betriebsrat then v_contract_changes := array_append(v_contract_changes, 'Betriebsrat'); end if;
if v_role ? 'has_dienstwagen' and (v_role->>'has_dienstwagen')::boolean <> v_old.has_dienstwagen then v_contract_changes := array_append(v_contract_changes, 'Dienstwagen'); end if;
if v_role ? 'is_laterale_fuehrung' and (v_role->>'is_laterale_fuehrung')::boolean <> v_old.is_laterale_fuehrung then v_contract_changes := array_append(v_contract_changes, 'Laterale Führung'); end if;
if v_role ? 'is_c_level' and (v_role->>'is_c_level')::boolean <> v_old.is_c_level then v_contract_changes := array_append(v_contract_changes, '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 array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 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 array_length(v_person_changes, 1) > 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: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
if array_length(v_contract_changes, 1) > 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: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
end if;
end;
$$;
-- ── apply_due_pending_changes: mirror title_prefix/title_suffix handling
-- in the deferred contract_change branch ──
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
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),
title_prefix = case when v_rec.payload->'person' ? 'title_prefix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_prefix') elem), '{}') else title_prefix end,
title_suffix = case when v_rec.payload->'person' ? 'title_suffix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_suffix') elem), '{}') else title_suffix end,
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),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_rec.payload->'role'->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_rec.payload->'role'->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_rec.payload->'role' ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'role'->'work_days') elem), '{}') else work_days end,
is_betriebsrat = coalesce((v_rec.payload->'role'->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_rec.payload->'role'->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_rec.payload->'role'->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_rec.payload->'role'->>'is_c_level')::boolean, is_c_level)
where id = v_rec.employee_id;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,214 @@
-- Angehörige add/remove now take a "Wirksam ab" like every other mutation
-- in Daten ändern — a future-dated add/remove is queued the same way
-- transfer/promotion/contract changes already are (see
-- 20260714120050_pending_org_changes.sql) and picked up by the existing
-- apply_due_pending_changes() cron job once due.
alter table pending_org_changes drop constraint pending_org_changes_change_type_check;
alter table pending_org_changes add constraint pending_org_changes_change_type_check check (change_type in (
'transfer', 'promotion', 'karenz_start', 'karenz_return', 'contract_change', 'reorg', 'dependent_add', 'dependent_remove'
));
-- ── Angehörige:n hinzufügen: now effective-dated ─────────────────────
-- Return type changes uuid -> void (a deferred add has no row yet to
-- return an id for), which CREATE OR REPLACE can't do in place.
drop function if exists add_employee_dependent(jsonb);
create function add_employee_dependent(payload jsonb)
returns void language plpgsql as $$
declare
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_employee_name text;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
v_dep_name text := (payload->>'first_name') || ' ' || (payload->>'last_name');
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_employee_name from employees where id = v_employee_id;
if not found then
raise exception 'Mitarbeiter:in nicht gefunden.';
end if;
if v_effective_date <= current_date then
insert into employee_dependents (employee_id, first_name, last_name, relationship, sv_nummer, birth_date)
values (
v_employee_id, payload->>'first_name', payload->>'last_name', payload->>'relationship',
nullif(payload->>'sv_nummer', ''), (payload->>'birth_date')::date
);
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_employee_id, 'dependent_add', v_effective_date, payload);
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (
v_employee_id, v_effective_date, 'Stammdatenänderung',
'Angehörige:r hinzugefügt: ' || v_dep_name || ' (' || (payload->>'relationship') || '), wirksam ab ' || v_effective_date
);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'Angehörige:r hinzugefügt', v_employee_name, v_employee_id,
v_dep_name || ' (' || (payload->>'relationship') || '), wirksam ab ' || v_effective_date
);
end;
$$;
-- ── Angehörige:n entfernen: now effective-dated ──────────────────────
create or replace function delete_employee_dependent(payload jsonb)
returns void language plpgsql as $$
declare
v_dep employee_dependents%rowtype;
v_employee_name text;
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
begin
perform require_hr_admin();
select * into v_dep from employee_dependents where id = (payload->>'dependent_id')::uuid;
if not found then
raise exception 'Angehörige:r nicht gefunden.';
end if;
select first_name || ' ' || last_name into v_employee_name from employees where id = v_dep.employee_id;
if v_effective_date <= current_date then
delete from employee_dependents where id = v_dep.id;
else
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
values (v_dep.employee_id, 'dependent_remove', v_effective_date, jsonb_build_object('dependent_id', v_dep.id));
end if;
insert into employee_history (employee_id, event_date, event_type, description)
values (
v_dep.employee_id, v_effective_date, 'Stammdatenänderung',
'Angehörige:r entfernt: ' || v_dep.first_name || ' ' || v_dep.last_name || ' (' || v_dep.relationship || '), wirksam ab ' || v_effective_date
);
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'Angehörige:r entfernt', v_employee_name, v_dep.employee_id,
v_dep.first_name || ' ' || v_dep.last_name || ' (' || v_dep.relationship || '), wirksam ab ' || v_effective_date
);
end;
$$;
-- ── apply_due_pending_changes: dependent_add/dependent_remove branches ──
create or replace function apply_due_pending_changes()
returns int
language plpgsql
security definer
set search_path = public
as $$
declare
v_rec record;
v_team_id uuid;
v_division_id uuid;
v_is_lead boolean;
v_manager uuid;
v_remaining int;
v_applied_count int := 0;
begin
for v_rec in
select * from pending_org_changes
where status = 'pending' and effective_date <= current_date
order by created_at
loop
if v_rec.change_type = 'transfer' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'new_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
update employees set
team_id = (v_rec.payload->>'new_team_id')::uuid,
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
manager_id = v_manager
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
where id = v_rec.employee_id;
elsif v_rec.change_type = 'karenz_return' then
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
from employees where id = v_rec.employee_id;
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
update employees set
status = 'Aktiv',
karenz_return_date = null,
karenz_start_date = null,
manager_id = v_manager,
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),
title_prefix = case when v_rec.payload->'person' ? 'title_prefix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_prefix') elem), '{}') else title_prefix end,
title_suffix = case when v_rec.payload->'person' ? 'title_suffix'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'person'->'title_suffix') elem), '{}') else title_suffix end,
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),
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date else contract_end_date end,
worker_type = coalesce((v_rec.payload->'role'->>'worker_type')::worker_type, worker_type),
collective_agreement = coalesce((v_rec.payload->'role'->>'collective_agreement')::collective_agreement, collective_agreement),
work_days = case when v_rec.payload->'role' ? 'work_days'
then coalesce((select array_agg(elem) from jsonb_array_elements_text(v_rec.payload->'role'->'work_days') elem), '{}') else work_days end,
is_betriebsrat = coalesce((v_rec.payload->'role'->>'is_betriebsrat')::boolean, is_betriebsrat),
has_dienstwagen = coalesce((v_rec.payload->'role'->>'has_dienstwagen')::boolean, has_dienstwagen),
is_laterale_fuehrung = coalesce((v_rec.payload->'role'->>'is_laterale_fuehrung')::boolean, is_laterale_fuehrung),
is_c_level = coalesce((v_rec.payload->'role'->>'is_c_level')::boolean, is_c_level)
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',
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;
elsif v_rec.change_type = 'reorg' then
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
where t.id = (v_rec.payload->>'target_team_id')::uuid;
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
where id = v_rec.employee_id;
end if;
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
v_applied_count := v_applied_count + 1;
if v_rec.reorg_scenario_id is not null then
select count(*) into v_remaining from pending_org_changes
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
if v_remaining = 0 then
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
end if;
end if;
end loop;
return v_applied_count;
end;
$$;
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
grant execute on function apply_due_pending_changes() to service_role;

View File

@@ -0,0 +1,107 @@
-- HR-Notizen: add-only Notizen zu einem Mitarbeiter, mit optionalem
-- "Wiedervorlage am"-Datum. Kein Bearbeiten/Löschen — ein Fehler wird nicht
-- korrigiert, sondern bleibt sichtbar (ggf. per neuer Notiz richtiggestellt),
-- gleicher Append-only-Geist wie employee_history/audit_log.
--
-- Bewusst NICHT nach Autor gescoped und NICHT effective-dated: anders als
-- employee_dependents sieht jede aktive HR-Person jede offene Notiz,
-- unabhängig davon wer sie geschrieben hat oder zu wem sie gehört ("Meine
-- Notizen" ist trotz des Namens ein geteiltes Team-Postfach), und eine
-- Notiz hat keinen "existiert erst ab einem künftigen Datum"-Zustand wie
-- eine Versetzung/Beförderung.
--
-- Kein Eintrag in employee_history: history_event_type ist ein fixer Enum
-- (siehe 20260601000000_initial_schema.sql) ohne passenden Wert, und
-- Historie ist explizit eine Beschäftigungsereignis-Timeline. Jede Mutation
-- schreibt stattdessen nur einen audit_log-Eintrag.
create table employee_notes (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
author_user_id uuid references auth.users(id),
author_name text not null,
category text not null default 'Allgemein' check (category in (
'Allgemein', 'Vertraulich', 'Personalgespräch', 'Wiedervorlage', 'Lob / Anerkennung'
)),
note_text text not null,
due_date date,
done boolean not null default false,
done_at timestamptz,
done_by uuid references auth.users(id),
created_at timestamptz not null default now()
);
create index on employee_notes (employee_id);
-- Deckt die "Meine Notizen"-Postfach-Query (loadOpenNotes) ab, die immer
-- auf done = false filtert.
create index on employee_notes (created_at desc) where not done;
-- RLS narrows only what an already-GRANTed role may do; die
-- "grant all ... to anon, authenticated, service_role"-Default-Privilegien
-- (20260714120500_default_grants.sql) decken die neue Tabelle bereits ab.
alter table employee_notes enable row level security;
-- Eine einzige Blanket-Policy, gleiches Muster wie `positions`: offen vs.
-- erledigt ist ein reiner App-Filter, nie eine RLS-Unterscheidung — jede
-- aktive HR-Person darf jede Notiz lesen/schreiben.
create policy "employee_notes_hr_all" on employee_notes for all
using (is_hr_user()) with check (is_hr_user());
-- ── HR-Notiz hinzufügen ───────────────────────────────────────────────
create or replace function add_employee_note(payload jsonb)
returns uuid language plpgsql as $$
declare
v_id uuid;
v_employee_id uuid := (payload->>'employee_id')::uuid;
v_employee_name text;
v_category text := coalesce(nullif(payload->>'category', ''), 'Allgemein');
v_note_text text := payload->>'note_text';
v_due_date date := nullif(payload->>'due_date', '')::date;
begin
perform require_hr_admin();
select first_name || ' ' || last_name into v_employee_name from employees where id = v_employee_id;
if not found then
raise exception 'Mitarbeiter:in nicht gefunden.';
end if;
if coalesce(btrim(v_note_text), '') = '' then
raise exception 'Notiztext darf nicht leer sein.';
end if;
insert into employee_notes (employee_id, author_user_id, author_name, category, note_text, due_date)
values (v_employee_id, auth.uid(), current_actor_name(), v_category, v_note_text, v_due_date)
returning id into v_id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'HR-Notiz hinzugefügt', v_employee_name, v_employee_id,
'[' || v_category || '] ' || left(v_note_text, 200) ||
case when v_due_date is not null then ', Wiedervorlage am ' || v_due_date else '' end
);
return v_id;
end;
$$;
-- ── HR-Notiz als erledigt markieren ───────────────────────────────────
create or replace function complete_employee_note(payload jsonb)
returns void language plpgsql as $$
declare
v_note employee_notes%rowtype;
v_employee_name text;
begin
perform require_hr_admin();
select * into v_note from employee_notes where id = (payload->>'note_id')::uuid;
if not found then
raise exception 'Notiz nicht gefunden.';
end if;
if v_note.done then
raise exception 'Notiz ist bereits erledigt.';
end if;
select first_name || ' ' || last_name into v_employee_name from employees where id = v_note.employee_id;
update employee_notes set done = true, done_at = now(), done_by = auth.uid() where id = v_note.id;
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
values (
auth.uid(), current_actor_name(), 'HR-Notiz erledigt', v_employee_name, v_note.employee_id,
'[' || v_note.category || '] ' || left(v_note.note_text, 200)
);
end;
$$;

View File

@@ -0,0 +1,120 @@
-- Org-assignment history, so the Organigramm can be shown as of any date.
--
-- Until now `employees` carried only the *current* placement (manager_id,
-- team_id, division_id, job_title, is_lead, org_level) and every mutation
-- overwrote it in place. employee_history recorded that something happened,
-- but only as free text — no old/new values — so a past reporting line was
-- gone for good. Forward-looking dates already worked (pending_org_changes
-- holds not-yet-due changes structurally); it was the past that could not be
-- reconstructed. This migration adds the missing timeline.
--
-- Captured by a trigger rather than by editing the mutating RPCs: there are
-- ~70 `update employees` statements spread over fifteen migrations (several
-- of which redefine the same function repeatedly), so per-RPC bookkeeping
-- would miss paths today and again with every future RPC. A trigger on the
-- table catches all of them, including ones not written yet.
create table employee_assignments (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
manager_id uuid references employees(id) on delete set null,
team_id uuid references teams(id),
division_id uuid not null references divisions(id),
job_title text not null,
is_lead boolean not null default false,
org_level int not null,
valid_from date not null,
-- Exclusive upper bound; null means "still in force". Note this tracks
-- *placement*, not employment: the row of someone who has left stays open,
-- because whether they were employed on a given date is derived separately
-- from entry/exit/karenz dates. Keeping the two apart is what lets a
-- rehire reuse the same open row instead of needing it reopened.
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 employee_assignments (employee_id, valid_from desc);
create index on employee_assignments (valid_from);
-- At most one open interval per employee — the invariant the trigger relies
-- on when it looks up "the current row" to close.
create unique index employee_assignments_one_open on employee_assignments (employee_id) where valid_to is null;
alter table employee_assignments enable row level security;
create policy "employee_assignments_hr_read" on employee_assignments for select using (is_hr_user());
-- ── Backfill ───────────────────────────────────────────────────────
-- One open interval per employee, starting at their entry date, holding
-- today's placement. Changes made *before* this migration are not
-- recoverable — employee_history never stored structured old values — so a
-- Stichtag before today's date shows the placement as it stands now for
-- anyone whose assignment predates this table. Everything from here on is
-- exact.
insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from)
select id, manager_id, team_id, division_id, job_title, is_lead, org_level, entry_date
from employees;
-- ── Trigger ────────────────────────────────────────────────────────
-- The date a change takes effect is normally the day it is written: an RPC
-- with a future effective date does not touch `employees` at all (it queues
-- into pending_org_changes, and apply_due_pending_changes writes it on the
-- due date), and one dated today writes immediately. A *backdated* change is
-- the exception — it writes immediately although it should take effect
-- earlier — so callers may set `app.effective_date` for the transaction to
-- say so explicitly; unset, it falls back to the current date.
create or replace function fn_track_employee_assignment()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
v_date date := coalesce(nullif(current_setting('app.effective_date', true), '')::date, current_date);
v_open_from date;
begin
if tg_op = 'INSERT' then
insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from)
values (new.id, new.manager_id, new.team_id, new.division_id, new.job_title, new.is_lead, new.org_level,
coalesce(new.entry_date, v_date));
return new;
end if;
if new.manager_id is not distinct from old.manager_id
and new.team_id is not distinct from old.team_id
and new.division_id is not distinct from old.division_id
and new.job_title is not distinct from old.job_title
and new.is_lead is not distinct from old.is_lead
and new.org_level is not distinct from old.org_level then
return new;
end if;
select valid_from into v_open_from
from employee_assignments where employee_id = new.id and valid_to is null;
if v_open_from is null then
insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from)
values (new.id, new.manager_id, new.team_id, new.division_id, new.job_title, new.is_lead, new.org_level, v_date);
elsif v_date <= v_open_from then
-- Two changes on the same day (or a backdate landing inside the open
-- interval): overwrite in place, so no zero-length or inverted interval
-- is ever stored.
update employee_assignments
set manager_id = new.manager_id, team_id = new.team_id, division_id = new.division_id,
job_title = new.job_title, is_lead = new.is_lead, org_level = new.org_level
where employee_id = new.id and valid_to is null;
else
update employee_assignments set valid_to = v_date where employee_id = new.id and valid_to is null;
insert into employee_assignments (employee_id, manager_id, team_id, division_id, job_title, is_lead, org_level, valid_from)
values (new.id, new.manager_id, new.team_id, new.division_id, new.job_title, new.is_lead, new.org_level, v_date);
end if;
return new;
end;
$$;
drop trigger if exists trg_track_employee_assignment on employees;
create trigger trg_track_employee_assignment
after insert or update on employees
for each row execute function fn_track_employee_assignment();
grant all on table employee_assignments to anon, authenticated, service_role;

View File

@@ -319,6 +319,8 @@ type EmployeeRow = {
sv_nummer: string; sv_nummer: string;
nationality: string; nationality: string;
address: string; address: string;
postal_code: string;
city: string;
address_country: string; address_country: string;
email: string; email: string;
phone: string; phone: string;
@@ -399,7 +401,9 @@ function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId
last_name: lastName, last_name: lastName,
gender, gender,
nationality, nationality,
address: `${pick(STREETS)} ${randInt(1, 90)}, ${homeLocale.postal()} ${homeLocale.city}`, address: `${pick(STREETS)} ${randInt(1, 90)}`,
postal_code: homeLocale.postal(),
city: homeLocale.city,
address_country: addressCountryFor(nationality), address_country: addressCountryFor(nationality),
email: makeEmail(firstName, lastName), email: makeEmail(firstName, lastName),
phone: `+43 664 ${randInt(1000000, 9999999)}`, phone: `+43 664 ${randInt(1000000, 9999999)}`,

View File

@@ -0,0 +1,140 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import type { Database } from "@/lib/supabase/types";
import {
adminClient,
createHrUser,
deleteTestEmployee,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededTeam,
signInAs,
type TestUser,
} from "./helpers";
// Org-assignment history (supabase/migrations/20260724120000_employee_
// assignment_history.sql). The point of capturing this with a trigger rather
// than inside each RPC is that it holds for *every* write path — so these
// tests drive the real RPCs and assert on the timeline they leave behind.
describe("employee_assignments history", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let teamA: { id: string };
let teamB: { id: string };
const employeeIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
teamA = await pickSeededTeam();
teamB = await pickSeededTeam(teamA.id);
});
afterAll(async () => {
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser);
});
async function freshEmployee(teamId: string): Promise<string> {
const id = await hireTestEmployee(hrClient, teamId);
employeeIds.push(id);
return id;
}
async function assignmentsFor(employeeId: string) {
const { data } = await adminClient
.from("employee_assignments")
.select("team_id, job_title, valid_from, valid_to")
.eq("employee_id", employeeId)
.order("valid_from");
return data ?? [];
}
it("opens an interval when an employee is hired", async () => {
const employeeId = await freshEmployee(teamA.id);
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(1);
expect(rows[0].team_id).toBe(teamA.id);
expect(rows[0].valid_to).toBeNull();
});
it("closes the old interval and opens a new one on transfer", async () => {
const employeeId = await freshEmployee(teamA.id);
const { error } = await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
});
expect(error).toBeNull();
const rows = await assignmentsFor(employeeId);
expect(rows).toHaveLength(2);
expect(rows[0].team_id).toBe(teamA.id);
expect(rows[0].valid_to).toBe(isoDateOffset(0));
expect(rows[1].team_id).toBe(teamB.id);
expect(rows[1].valid_to).toBeNull();
// Intervals must abut exactly, or an as-of query lands in a gap.
expect(rows[1].valid_from).toBe(rows[0].valid_to);
});
it("rewrites in place rather than leaving a zero-length interval for a same-day second move", async () => {
const employeeId = await freshEmployee(teamA.id);
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
});
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamA.id },
});
const rows = await assignmentsFor(employeeId);
expect(rows.every((r) => r.valid_to === null || r.valid_to > r.valid_from)).toBe(true);
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
expect(rows.at(-1)?.team_id).toBe(teamA.id);
});
it("records a promotion's new title as its own interval", async () => {
const employeeId = await freshEmployee(teamA.id);
const { error } = await hrClient.rpc("promote_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_title: "Senior Testtitel" },
});
expect(error).toBeNull();
const rows = await assignmentsFor(employeeId);
expect(rows.at(-1)?.job_title).toBe("Senior Testtitel");
expect(rows.at(-1)?.valid_to).toBeNull();
});
it("writes no new interval when nothing about the placement changed", async () => {
const employeeId = await freshEmployee(teamA.id);
const before = await assignmentsFor(employeeId);
const { error } = await hrClient.rpc("change_employee_data", {
payload: {
employee_id: employeeId,
effective_date: isoDateOffset(0),
person: { phone: "+43 1 2345678" },
contract: {},
role: {},
},
});
expect(error).toBeNull();
expect(await assignmentsFor(employeeId)).toHaveLength(before.length);
});
it("keeps exactly one open interval per employee", async () => {
const employeeId = await freshEmployee(teamA.id);
await hrClient.rpc("transfer_employee", {
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
});
const rows = await assignmentsFor(employeeId);
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
});
it("is not readable without an active HR session", async () => {
const outsider = await createHrUser({ active: false });
const outsiderClient = await signInAs(outsider);
const { data } = await outsiderClient.from("employee_assignments").select("id").limit(1);
expect(data ?? []).toHaveLength(0);
await deleteTestUser(outsider);
});
});

View File

@@ -144,3 +144,29 @@ export async function deleteTestEmployee(employeeId: string): Promise<void> {
await adminClient.from("audit_log").delete().eq("target_employee_id", employeeId); await adminClient.from("audit_log").delete().eq("target_employee_id", employeeId);
await adminClient.from("employees").delete().eq("id", employeeId); await adminClient.from("employees").delete().eq("id", employeeId);
} }
// Creates a throwaway open position via the real create_position RPC.
// Defaults to a non-lead position reporting to `superiorEmployeeId` (its
// team is derived from that employee's own team, same as the app does).
// Caller must clean up with deleteTestPosition — and, since positions.
// reports_to_employee_id / filled_by_employee_id reference employees(id)
// with no cascade, delete positions before the employees they point to.
export async function createTestPosition(
hrClient: SupabaseClient<Database>,
superiorEmployeeId: string,
overrides: Partial<Record<string, unknown>> = {}
): Promise<string> {
const payload = {
title: `Integrationstest-Position-${randomUUID().slice(0, 8)}`,
superior_employee_id: superiorEmployeeId,
is_lead: false,
...overrides,
};
const { data, error } = await hrClient.rpc("create_position", { payload });
if (error || !data) throw new Error(`createTestPosition failed: ${error?.message ?? "no id returned"}`);
return data;
}
export async function deleteTestPosition(positionId: string): Promise<void> {
await adminClient.from("positions").delete().eq("id", positionId);
}

View File

@@ -0,0 +1,156 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
adminClient,
createHrUser,
createTestPosition,
deleteTestEmployee,
deleteTestPosition,
deleteTestUser,
hireTestEmployee,
isoDateOffset,
pickSeededLocation,
pickSeededTeam,
signInAs,
teamLeadId,
type TestUser,
} from "./helpers";
import type { SupabaseClient } from "@supabase/supabase-js";
import type { Database } from "@/lib/supabase/types";
// Position validity window + delete (supabase/migrations/20260716120000_position_validity_and_delete.sql):
// positions now carry a required valid_from ("gültig ab") date, an open
// position can be deleted again, and neither internal staffing nor an
// external hire may assign an employee to a position before that date.
describe("position validity and delete", () => {
let hrUser: TestUser;
let hrClient: SupabaseClient<Database>;
let teamA: { id: string };
let superiorId: string;
const positionIds: string[] = [];
const employeeIds: string[] = [];
beforeAll(async () => {
hrUser = await createHrUser({ active: true });
hrClient = await signInAs(hrUser);
teamA = await pickSeededTeam();
superiorId = (await teamLeadId(teamA.id))!;
});
afterAll(async () => {
// Positions first: reports_to_employee_id / filled_by_employee_id
// reference employees(id) with no cascade.
for (const id of positionIds) await deleteTestPosition(id);
for (const id of employeeIds) await deleteTestEmployee(id);
await deleteTestUser(hrUser);
});
it("create_position records the given valid_from", async () => {
const validFrom = isoDateOffset(10);
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom });
positionIds.push(positionId);
const { data } = await adminClient.from("positions").select("valid_from").eq("id", positionId).single();
expect(data?.valid_from).toBe(validFrom);
});
it("create_position defaults valid_from to today when omitted", async () => {
const positionId = await createTestPosition(hrClient, superiorId);
positionIds.push(positionId);
const { data } = await adminClient.from("positions").select("valid_from").eq("id", positionId).single();
expect(data?.valid_from).toBe(isoDateOffset(0));
});
it("delete_position removes an open position", async () => {
const positionId = await createTestPosition(hrClient, superiorId);
const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } });
expect(error).toBeNull();
const { data } = await adminClient.from("positions").select("id").eq("id", positionId).maybeSingle();
expect(data).toBeNull();
});
it("delete_position rejects a filled position", async () => {
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(-10) });
positionIds.push(positionId);
const employeeId = await hireTestEmployee(hrClient, teamA.id);
employeeIds.push(employeeId);
const { error: staffError } = await hrClient.rpc("staff_position_internally", {
payload: { position_id: positionId, employee_id: employeeId },
});
expect(staffError).toBeNull();
const { error } = await hrClient.rpc("delete_position", { payload: { position_id: positionId } });
expect(error?.message).toMatch(/Nur offene Positionen können gelöscht werden/);
});
it("staff_position_internally rejects assigning to a position before its valid_from", async () => {
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(10) });
positionIds.push(positionId);
const employeeId = await hireTestEmployee(hrClient, teamA.id);
employeeIds.push(employeeId);
const { error } = await hrClient.rpc("staff_position_internally", {
payload: { position_id: positionId, employee_id: employeeId },
});
expect(error?.message).toMatch(/erst ab .* gültig/);
});
it("staff_position_internally accepts assigning to a position on/after its valid_from", async () => {
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: isoDateOffset(-1) });
positionIds.push(positionId);
const employeeId = await hireTestEmployee(hrClient, teamA.id);
employeeIds.push(employeeId);
const { error } = await hrClient.rpc("staff_position_internally", {
payload: { position_id: positionId, employee_id: employeeId },
});
expect(error).toBeNull();
});
it("hire_employee rejects an entry_date before the position's valid_from", async () => {
const validFrom = isoDateOffset(10);
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom });
positionIds.push(positionId);
const location = await pickSeededLocation();
const { error } = await hrClient.rpc("hire_employee", {
payload: {
first_name: "Integrationstest",
last_name: `Person-${randomUUID().slice(0, 8)}`,
gender: "w",
birth_date: "1990-01-01",
location_id: location.id,
position_id: positionId,
entry_date: isoDateOffset(5),
source: "Extern",
},
});
expect(error?.message).toMatch(/Eintrittsdatum darf nicht vor dem Gültigkeitsbeginn/);
});
it("hire_employee accepts an entry_date on/after the position's valid_from", async () => {
const validFrom = isoDateOffset(10);
const positionId = await createTestPosition(hrClient, superiorId, { valid_from: validFrom });
positionIds.push(positionId);
const location = await pickSeededLocation();
const { data, error } = await hrClient.rpc("hire_employee", {
payload: {
first_name: "Integrationstest",
last_name: `Person-${randomUUID().slice(0, 8)}`,
gender: "w",
birth_date: "1990-01-01",
location_id: location.id,
position_id: positionId,
entry_date: validFrom,
source: "Extern",
},
});
expect(error).toBeNull();
if (data) employeeIds.push(data);
});
});

View File

@@ -1,11 +1,25 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { daysBetween, fmtAge, fmtDate, initials, tenure } from "@/lib/format"; import { addDaysIso, daysBetweenIso, fmtAge, fmtDate, initials, tenure, toIsoDate, yearsBetweenIso } from "@/lib/format";
describe("fmtDate", () => { describe("fmtDate", () => {
it("formats an ISO date string in de-AT order", () => { it("formats an ISO date string in de-AT order", () => {
expect(fmtDate("2026-03-05")).toBe("05.03.2026"); expect(fmtDate("2026-03-05")).toBe("05.03.2026");
}); });
// A date-only column has no time and no zone. Routing it through a Date
// would anchor it to UTC midnight and render the previous day wherever the
// renderer sits west of UTC — including a server/browser hydration split.
it("never shifts a date-only string across a day boundary", () => {
expect(fmtDate("2026-01-01")).toBe("01.01.2026");
expect(fmtDate("2026-12-31")).toBe("31.12.2026");
});
it("renders a timestamp in Vienna time regardless of the runtime zone", () => {
// 23:30 UTC on 5 March is already 6 March in Vienna (UTC+1 before the
// DST switch); the same instant is still 5 March in UTC.
expect(fmtDate("2026-03-05T23:30:00Z")).toBe("06.03.2026");
});
it("returns an em dash for null/undefined/empty input", () => { it("returns an em dash for null/undefined/empty input", () => {
expect(fmtDate(null)).toBe(""); expect(fmtDate(null)).toBe("");
expect(fmtDate(undefined)).toBe(""); expect(fmtDate(undefined)).toBe("");
@@ -67,12 +81,47 @@ describe("tenure", () => {
}); });
}); });
describe("daysBetween", () => { describe("daysBetweenIso", () => {
it("computes whole days between two dates", () => { it("computes whole days between two dates", () => {
expect(daysBetween("2026-01-01", "2026-01-11")).toBe(10); expect(daysBetweenIso("2026-01-01", "2026-01-11")).toBe(10);
}); });
it("returns a negative number when the second date precedes the first", () => { it("returns a negative number when the second date precedes the first", () => {
expect(daysBetween("2026-01-11", "2026-01-01")).toBe(-10); expect(daysBetweenIso("2026-01-11", "2026-01-01")).toBe(-10);
});
// Both ends are anchored at UTC midnight, so the DST switch in between
// cannot turn 30 calendar days into 29.96 and round down.
it("is exact across a DST transition", () => {
expect(daysBetweenIso("2026-03-15", "2026-04-15")).toBe(31);
expect(daysBetweenIso("2026-10-15", "2026-11-15")).toBe(31);
});
});
describe("addDaysIso", () => {
it("rolls over month and year boundaries", () => {
expect(addDaysIso("2026-12-31", 1)).toBe("2027-01-01");
expect(addDaysIso("2026-01-31", 1)).toBe("2026-02-01");
});
it("handles a leap day", () => {
expect(addDaysIso("2028-02-28", 1)).toBe("2028-02-29");
});
});
describe("yearsBetweenIso", () => {
it("counts only completed years", () => {
expect(yearsBetweenIso("1990-05-15", "2026-05-15")).toBe(36);
expect(yearsBetweenIso("1990-05-15", "2026-05-14")).toBe(35);
});
});
describe("toIsoDate", () => {
it("passes a date-only string through untouched", () => {
expect(toIsoDate("2026-01-01")).toBe("2026-01-01");
});
it("resolves a timestamp to the Vienna calendar day", () => {
expect(toIsoDate("2026-03-05T23:30:00Z")).toBe("2026-03-06");
}); });
}); });

View File

@@ -0,0 +1,168 @@
import { describe, expect, it } from "vitest";
import { resolveOrgSnapshot } from "@/lib/orgchart-data";
const DIV = "div-1";
const TEAM_A = "team-a";
const TEAM_B = "team-b";
type EmployeeInput = Parameters<typeof resolveOrgSnapshot>[0]["employees"][number];
type AssignmentInput = Parameters<typeof resolveOrgSnapshot>[0]["assignments"][number];
function emp(id: string, overrides: Partial<EmployeeInput> = {}): EmployeeInput {
return {
id,
personnel_number: 1000,
first_name: "Test",
last_name: id,
job_title: "Mitarbeiter:in",
manager_id: null,
team_id: TEAM_A,
division_id: DIV,
is_lead: false,
org_level: 3,
entry_date: "2020-01-01",
exit_date: null,
karenz_start_date: null,
karenz_return_date: null,
...overrides,
};
}
function assignment(employeeId: string, overrides: Partial<AssignmentInput> = {}): AssignmentInput {
return {
employee_id: employeeId,
manager_id: null,
team_id: TEAM_A,
division_id: DIV,
job_title: "Mitarbeiter:in",
is_lead: false,
org_level: 3,
valid_from: "2020-01-01",
...overrides,
};
}
const TEAMS = [
{ id: TEAM_A, department_id: "dept-1" },
{ id: TEAM_B, department_id: "dept-2" },
];
const DEPARTMENTS = [
{ id: "dept-1", division_id: DIV },
{ id: "dept-2", division_id: "div-2" },
];
function snapshot(args: Partial<Parameters<typeof resolveOrgSnapshot>[0]> & { asOf: string }) {
return resolveOrgSnapshot({ employees: [], assignments: [], teams: TEAMS, departments: DEPARTMENTS, pending: [], ...args });
}
describe("membership as of a date", () => {
it("excludes someone who had not started yet and includes them once they have", () => {
const employees = [emp("a", { entry_date: "2026-06-01" })];
expect(snapshot({ asOf: "2026-05-31", employees }).employees).toHaveLength(0);
expect(snapshot({ asOf: "2026-06-01", employees }).employees).toHaveLength(1);
});
it("excludes someone from their exit date onwards", () => {
const employees = [emp("a", { exit_date: "2026-06-30" })];
expect(snapshot({ asOf: "2026-06-29", employees }).employees).toHaveLength(1);
expect(snapshot({ asOf: "2026-06-30", employees }).employees).toHaveLength(0);
});
it("keeps someone on Karenz in the chart", () => {
const employees = [emp("a", { karenz_start_date: "2026-01-01", karenz_return_date: "2026-12-01" })];
expect(snapshot({ asOf: "2026-06-01", employees }).employees).toHaveLength(1);
});
});
describe("placement as of a date", () => {
it("uses the assignment interval covering the date, not today's row on employees", () => {
const employees = [emp("a", { team_id: TEAM_B, job_title: "Heutiger Titel" })];
const assignments = [assignment("a", { team_id: TEAM_A, job_title: "Damaliger Titel" })];
const [result] = snapshot({ asOf: "2024-03-01", employees, assignments }).employees;
expect(result.team_id).toBe(TEAM_A);
expect(result.job_title).toBe("Damaliger Titel");
});
it("falls back to the employee row when no assignment covers the date", () => {
const employees = [emp("a", { team_id: TEAM_B })];
const [result] = snapshot({ asOf: "2024-03-01", employees, assignments: [] }).employees;
expect(result.team_id).toBe(TEAM_B);
});
});
describe("orphan re-rooting", () => {
// Without this the whole reporting line below an absent manager silently
// disappears from the chart instead of moving up a level.
it("drops a manager reference to somebody not employed on that date", () => {
const employees = [
emp("boss", { exit_date: "2026-01-01", org_level: 2, is_lead: true }),
emp("report", { manager_id: "boss" }),
];
const assignments = [assignment("report", { manager_id: "boss" })];
const result = snapshot({ asOf: "2026-06-01", employees, assignments }).employees;
expect(result).toHaveLength(1);
expect(result[0].id).toBe("report");
expect(result[0].manager_id).toBeNull();
});
});
describe("future projection from pending changes", () => {
const leadB = emp("lead-b", { id: "lead-b", team_id: TEAM_B, division_id: "div-2", is_lead: true, org_level: 2 });
it("moves an employee into the target team and under that team's lead", () => {
const employees = [emp("a", { manager_id: "lead-a" }), leadB];
const assignments = [assignment("a", { manager_id: "lead-a" }), assignment("lead-b", { team_id: TEAM_B, division_id: "div-2", is_lead: true, org_level: 2 })];
const pending = [{ employee_id: "a", effective_date: "2026-08-01", payload: { new_team_id: TEAM_B } }];
const result = snapshot({ asOf: "2026-09-01", employees, assignments, pending });
const moved = result.employees.find((e) => e.id === "a")!;
expect(moved.team_id).toBe(TEAM_B);
expect(moved.division_id).toBe("div-2");
expect(moved.manager_id).toBe("lead-b");
expect(result.projectedCount).toBe(1);
});
it("lets a later change win over an earlier one", () => {
const employees = [emp("a")];
const assignments = [assignment("a")];
const pending = [
{ employee_id: "a", effective_date: "2026-08-01", payload: { new_team_id: TEAM_B, new_title: "Zwischenstand" } },
{ employee_id: "a", effective_date: "2026-09-01", payload: { new_team_id: TEAM_A, new_title: "Endstand" } },
];
const [result] = snapshot({ asOf: "2026-10-01", employees, assignments, pending }).employees;
expect(result.team_id).toBe(TEAM_A);
expect(result.job_title).toBe("Endstand");
});
it("leaves an untouched employee's manager exactly as recorded", () => {
// Deliberately deviating from the resolve rule: real data drifts, and a
// snapshot must not silently "repair" reporting lines it was not asked
// to change.
const employees = [emp("a", { manager_id: "someone-else" }), emp("someone-else", { id: "someone-else" }), leadB];
const assignments = [assignment("a", { manager_id: "someone-else" })];
const [result] = snapshot({ asOf: "2026-09-01", employees, assignments }).employees;
expect(result.manager_id).toBe("someone-else");
});
it("ignores pending changes for a date the caller did not ask about", () => {
// loadOrgAsOf only fetches pending rows for a future date, so an empty
// list here must simply mean "no projection", not "drop the employee".
const employees = [emp("a")];
const assignments = [assignment("a")];
const result = snapshot({ asOf: "2026-09-01", employees, assignments, pending: [] });
expect(result.projectedCount).toBe(0);
expect(result.employees).toHaveLength(1);
});
});
describe("historyStartsAt", () => {
it("reports the earliest recorded assignment so the UI can flag older dates", () => {
const employees = [emp("a"), emp("b", { id: "b" })];
const assignments = [assignment("a", { valid_from: "2023-05-01" }), assignment("b", { valid_from: "2021-02-01" })];
expect(snapshot({ asOf: "2026-01-01", employees, assignments }).historyStartsAt).toBe("2021-02-01");
});
it("is null when nothing is recorded yet", () => {
expect(snapshot({ asOf: "2026-01-01", employees: [emp("a")] }).historyStartsAt).toBeNull();
});
});

View File

@@ -4,6 +4,7 @@ import {
aggregateReport, aggregateReport,
deriveStatusAsOf, deriveStatusAsOf,
groupKeyFor, groupKeyFor,
groupKeysFor,
measureValue, measureValue,
MEASURE_LABELS, MEASURE_LABELS,
type OrgLookups, type OrgLookups,
@@ -30,6 +31,14 @@ function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
paygrade: "B", paygrade: "B",
birth_date: "1990-01-01", birth_date: "1990-01-01",
gender: "w", gender: "w",
worker_type: "Angestellte:r",
collective_agreement: "Handel",
work_days: ["Mo", "Di", "Mi", "Do", "Fr"],
is_betriebsrat: false,
has_dienstwagen: false,
is_laterale_fuehrung: false,
is_c_level: false,
dependents_count: 0,
...overrides, ...overrides,
}; };
} }
@@ -71,6 +80,35 @@ describe("groupKeyFor", () => {
it("derives entry_year from entry_date", () => { it("derives entry_year from entry_date", () => {
expect(groupKeyFor(emp({ entry_date: "2019-06-01" }), "entry_year", lookups)).toBe("2019"); expect(groupKeyFor(emp({ entry_date: "2019-06-01" }), "entry_year", lookups)).toBe("2019");
}); });
it("resolves the Rolle & Anstellung boolean dimensions as Ja/Nein", () => {
const e = emp({ is_betriebsrat: true, has_dienstwagen: false, is_laterale_fuehrung: true, is_c_level: false });
expect(groupKeyFor(e, "betriebsrat", lookups)).toBe("Ja");
expect(groupKeyFor(e, "dienstwagen", lookups)).toBe("Nein");
expect(groupKeyFor(e, "laterale_fuehrung", lookups)).toBe("Ja");
expect(groupKeyFor(e, "c_level", lookups)).toBe("Nein");
});
it("resolves worker_type/collective_agreement directly", () => {
const e = emp({ worker_type: "Arbeiter:in", collective_agreement: "Süßwaren" });
expect(groupKeyFor(e, "worker_type", lookups)).toBe("Arbeiter:in");
expect(groupKeyFor(e, "collective_agreement", lookups)).toBe("Süßwaren");
});
it("resolves has_dependents from dependents_count", () => {
expect(groupKeyFor(emp({ dependents_count: 0 }), "has_dependents", lookups)).toBe("Nein");
expect(groupKeyFor(emp({ dependents_count: 2 }), "has_dependents", lookups)).toBe("Ja");
});
});
describe("groupKeysFor", () => {
it("returns a single-element array for every ordinary dimension", () => {
expect(groupKeysFor(emp(), "division", lookups)).toEqual(["Produktion"]);
});
it("returns one key per work day for the weekday dimension", () => {
expect(groupKeysFor(emp({ work_days: ["Mo", "Mi"] }), "weekday", lookups)).toEqual(["Mo", "Mi"]);
});
}); });
describe("measureValue", () => { describe("measureValue", () => {
@@ -118,6 +156,11 @@ describe("measureValue", () => {
]; ];
expect(measureValue(rows, "avg_tenure")).toBeCloseTo(3, 0); expect(measureValue(rows, "avg_tenure")).toBeCloseTo(3, 0);
}); });
it("computes avg_dependents as the mean dependents_count", () => {
const rows = [emp({ dependents_count: 0 }), emp({ dependents_count: 2 }), emp({ dependents_count: 4 })];
expect(measureValue(rows, "avg_dependents")).toBeCloseTo(2, 5);
});
}); });
describe("aggregateReport", () => { describe("aggregateReport", () => {
@@ -173,6 +216,23 @@ describe("aggregateReport", () => {
expect(measureValue(employees, "avg_age", asOf)).toBe(30); expect(measureValue(employees, "avg_age", asOf)).toBe(30);
expect(measureValue(employees, "avg_tenure", asOf)).toBeCloseTo(1.42, 1); expect(measureValue(employees, "avg_tenure", asOf)).toBeCloseTo(1.42, 1);
}); });
it("counts an employee once per work day for the weekday dimension, and sorts Mo→So instead of by value", () => {
const employees = [
emp({ id: "1", work_days: ["Mo", "Di", "Mi", "Do", "Fr"] }),
emp({ id: "2", work_days: ["Mo", "Mi", "Fr"] }),
];
const rows = aggregateReport(employees, "headcount", "weekday", null, lookups);
expect(rows.map((r) => r.key)).toEqual(["Mo", "Di", "Mi", "Do", "Fr"]);
expect(rows.find((r) => r.key === "Mo")).toMatchObject({ value: 2, count: 2 });
expect(rows.find((r) => r.key === "Di")).toMatchObject({ value: 1, count: 1 });
});
it("sorts a weekday split chronologically within each group", () => {
const employees = [emp({ id: "1", division_id: "div-1", work_days: ["Fr", "Mo"] })];
const rows = aggregateReport(employees, "headcount", "division", "weekday", lookups);
expect(rows[0].split?.map((s) => s.key)).toEqual(["Mo", "Fr"]);
});
}); });
describe("deriveStatusAsOf", () => { describe("deriveStatusAsOf", () => {

Some files were not shown because too many files have changed in this diff Show More