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:
20
.dockerignore
Normal file
20
.dockerignore
Normal 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
|
||||
@@ -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_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=
|
||||
|
||||
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -46,7 +46,14 @@ next-env.d.ts
|
||||
/supabase/.temp
|
||||
/supabase/snippets
|
||||
|
||||
# per-machine (permission allowlists) and must stay out of the repo
|
||||
|
||||
# playwright
|
||||
/playwright-report
|
||||
/test-results
|
||||
/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
156
DEPLOYMENT.md
Normal 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
48
Dockerfile
Normal 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
154
README.md
@@ -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
|
||||
npm install
|
||||
cp .env.example .env.local # Werte eintragen, siehe unten
|
||||
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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
| Variable | Sichtbarkeit | Zweck |
|
||||
|---|---|---|
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Browser + Server | Supabase-Projekt-URL |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser + Server | Anon-Key, RLS-gebunden |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | **Nur Server** | Umgeht RLS vollständig — niemals im Browser-Bundle, niemals loggen |
|
||||
| `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.
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function login(formData: FormData) {
|
||||
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||
|
||||
if (error) {
|
||||
redirect(`/login?error=${encodeURIComponent("E-Mail oder Passwort ist falsch.")}`);
|
||||
redirect("/login?error=invalid_credentials");
|
||||
}
|
||||
|
||||
redirect("/");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
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 MutationFn = keyof Database["public"]["Functions"];
|
||||
@@ -19,6 +19,8 @@ async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalid
|
||||
export async function hireEmployee(payload: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
title_prefix?: string[];
|
||||
title_suffix?: string[];
|
||||
gender: "m" | "w";
|
||||
birth_date: string;
|
||||
sv_nummer?: string;
|
||||
@@ -34,6 +36,13 @@ export async function hireEmployee(payload: {
|
||||
weekly_hours?: number;
|
||||
paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
|
||||
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 }> {
|
||||
const supabase = await createClient();
|
||||
const { data, error } = await supabase.rpc("hire_employee", { payload });
|
||||
@@ -102,6 +111,7 @@ export async function changeEmployeeData(payload: {
|
||||
effective_date: string;
|
||||
person: Record<string, unknown>;
|
||||
contract: Record<string, unknown>;
|
||||
role: Record<string, unknown>;
|
||||
}): Promise<ActionResult> {
|
||||
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", "/"]);
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
// 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/
|
||||
// on-leave employees by name or title.
|
||||
export async function searchActiveEmployees(query: string): Promise<EmployeeSearchResult[]> {
|
||||
|
||||
@@ -6,34 +6,39 @@ import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
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: {
|
||||
title: string;
|
||||
superior_employee_id: string;
|
||||
is_lead: boolean;
|
||||
team_id?: string;
|
||||
valid_from: string;
|
||||
}): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
const { error } = await supabase.rpc("create_position", { payload });
|
||||
if (error) return { success: false, error: error.message };
|
||||
revalidatePath("/positions");
|
||||
revalidatePath("/orgchart");
|
||||
revalidatePath("/");
|
||||
return { success: true };
|
||||
return callRpc("create_position", payload, POSITION_PATHS);
|
||||
}
|
||||
|
||||
export async function deletePosition(positionId: string): Promise<ActionResult> {
|
||||
return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS);
|
||||
}
|
||||
|
||||
export async function staffPositionInternally(payload: {
|
||||
position_id: string;
|
||||
employee_id: string;
|
||||
}): Promise<ActionResult> {
|
||||
const supabase = await createClient();
|
||||
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 };
|
||||
return callRpc("staff_position_internally", payload, [...POSITION_PATHS, "/employees", `/employees/${payload.employee_id}`]);
|
||||
}
|
||||
|
||||
export type SuperiorSearchResult = { id: string; first_name: string; last_name: string; job_title: string; division_id: string };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { AuditFilters } from "@/components/audit/AuditFilters";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
@@ -17,11 +18,18 @@ function pageHref(params: SearchParams, page: number): string {
|
||||
return `/audit?${sp.toString()}`;
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
return new Intl.DateTimeFormat("de-AT", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }).format(
|
||||
new Date(iso)
|
||||
);
|
||||
}
|
||||
// Pinned to Vienna and built once: audit_log.occurred_at is a timestamptz, and
|
||||
// an unpinned formatter renders it in the *server's* zone — UTC in Docker and
|
||||
// on Vercel — so every entry would read an hour or two early for the people
|
||||
// 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> }) {
|
||||
const params = await searchParams;
|
||||
@@ -68,7 +76,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
||||
{(entries ?? []).map((entry) => {
|
||||
return (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<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>
|
||||
)}
|
||||
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => pageHref(params, p)} label="Audit-Log" />
|
||||
|
||||
<p className="text-xs text-ink-muted">
|
||||
Alle Änderungen an Personal-Stammdaten werden automatisch protokolliert und sind unveränderbar.
|
||||
|
||||
@@ -11,23 +11,35 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
const { data: employee } = await supabase.from("employees").select("*").eq("id", id).single();
|
||||
if (!employee) notFound();
|
||||
|
||||
const [{ data: manager }, { data: directReports }, { data: history }, { data: divisions }, { data: departments }, { data: teams }, { data: locations }, { data: openPositions }] =
|
||||
await Promise.all([
|
||||
employee.manager_id
|
||||
? supabase.from("employees").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single()
|
||||
: Promise.resolve({ data: null }),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, status")
|
||||
.eq("manager_id", id)
|
||||
.order("last_name"),
|
||||
supabase.from("employee_history").select("*").eq("employee_id", id).order("event_date", { ascending: false }).order("created_at", { ascending: false }),
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
supabase.from("departments").select("*"),
|
||||
supabase.from("teams").select("*"),
|
||||
supabase.from("locations").select("*").order("name"),
|
||||
supabase.from("positions").select("id, position_number, title, team_id, is_lead").eq("status", "open"),
|
||||
]);
|
||||
const [
|
||||
{ data: 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
|
||||
? supabase.from("employees").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single()
|
||||
: Promise.resolve({ data: null }),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, status")
|
||||
.eq("manager_id", id)
|
||||
.order("last_name"),
|
||||
supabase.from("employee_history").select("*").eq("employee_id", id).order("event_date", { ascending: false }).order("created_at", { ascending: false }),
|
||||
supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"),
|
||||
supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }),
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
supabase.from("departments").select("*"),
|
||||
supabase.from("teams").select("*"),
|
||||
supabase.from("locations").select("*").order("name"),
|
||||
supabase.from("positions").select("id, position_number, title, team_id, is_lead").eq("status", "open"),
|
||||
]);
|
||||
|
||||
return (
|
||||
<EmployeeDetail
|
||||
@@ -35,6 +47,8 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
manager={manager ?? null}
|
||||
directReports={directReports ?? []}
|
||||
history={history ?? []}
|
||||
dependents={dependents ?? []}
|
||||
notes={notes ?? []}
|
||||
divisions={divisions ?? []}
|
||||
departments={departments ?? []}
|
||||
teams={teams ?? []}
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { EmployeeFilters } from "@/components/employees/EmployeeFilters";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { StatusChip } from "@/components/ui/StatusChip";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
|
||||
@@ -126,19 +127,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<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>
|
||||
)}
|
||||
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => pageHref(params, p)} label="Mitarbeiter:innen" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
||||
import { Sidebar } from "@/components/shell/Sidebar";
|
||||
import { Topbar } from "@/components/shell/Topbar";
|
||||
import { AppShell } from "@/components/shell/AppShell";
|
||||
import { loadOpenNotes } from "@/lib/notes";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
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 [openPositions, locationsRes, draftsRes] = await Promise.all([
|
||||
const [openPositions, locationsRes, draftsRes, openNotes] = await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("locations").select("id, name, country").order("name"),
|
||||
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
||||
loadOpenNotes(supabase),
|
||||
]);
|
||||
|
||||
return (
|
||||
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="ml-[236px] flex flex-1 flex-col">
|
||||
<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>
|
||||
<AppShell userLabel={userLabel} openNotes={openNotes}>
|
||||
{children}
|
||||
</AppShell>
|
||||
</HireWizardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,55 @@
|
||||
import { Suspense } from "react";
|
||||
import { OrgChartClient } from "@/components/orgchart/OrgChartClient";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { parseIsoDateParam } from "@/lib/reports";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
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 [
|
||||
{ data: employees },
|
||||
{ data: divisions },
|
||||
{ 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("departments").select("*"),
|
||||
supabase.from("teams").select("*"),
|
||||
loadOpenPositions(supabase),
|
||||
supabase
|
||||
.from("reorg_scenarios")
|
||||
.select("id, name, effective_date, applied, applied_at")
|
||||
.eq("applied", true)
|
||||
.order("applied_at", { ascending: false })
|
||||
.limit(5),
|
||||
]);
|
||||
const [org, { data: divisions }, { data: departments }, { data: teams }, openPositions, { data: reorgScenarios }] =
|
||||
await Promise.all([
|
||||
loadOrgAsOf(supabase, asOf),
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
supabase.from("departments").select("*"),
|
||||
supabase.from("teams").select("*"),
|
||||
loadOpenPositions(supabase),
|
||||
supabase
|
||||
.from("reorg_scenarios")
|
||||
.select("id, name, effective_date, applied, applied_at")
|
||||
.eq("applied", true)
|
||||
.order("applied_at", { ascending: false })
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
return (
|
||||
<OrgChartClient
|
||||
employees={employees ?? []}
|
||||
divisions={divisions ?? []}
|
||||
departments={departments ?? []}
|
||||
teams={teams ?? []}
|
||||
openPositions={openPositions}
|
||||
reorgScenarios={reorgScenarios ?? []}
|
||||
/>
|
||||
<Suspense>
|
||||
<OrgChartClient
|
||||
employees={org.employees}
|
||||
divisions={divisions ?? []}
|
||||
departments={departments ?? []}
|
||||
teams={teams ?? []}
|
||||
openPositions={openPositions}
|
||||
reorgScenarios={reorgScenarios ?? []}
|
||||
asOf={asOf}
|
||||
today={today}
|
||||
projectedCount={org.projectedCount}
|
||||
historyStartsAt={org.historyStartsAt}
|
||||
focusId={focusId}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import Link from "next/link";
|
||||
import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
import { fetchAllRows } from "@/lib/supabase/query";
|
||||
|
||||
const TONE_TEXT: Record<string, string> = {
|
||||
default: "text-ink",
|
||||
@@ -45,13 +42,15 @@ export default async function DashboardPage() {
|
||||
.order("updated_at", { ascending: false })
|
||||
: { data: [] };
|
||||
|
||||
const today = new Date();
|
||||
const todayIso = isoDate(today);
|
||||
const yearStart = isoDate(new Date(today.getFullYear(), 0, 1));
|
||||
const yearEnd = isoDate(new Date(today.getFullYear(), 11, 31));
|
||||
const in60 = new Date(today);
|
||||
in60.setDate(in60.getDate() + 60);
|
||||
const in60Iso = isoDate(in60);
|
||||
// Built as strings, not by round-tripping a local Date through
|
||||
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
|
||||
// the previous year in UTC, which shifted the whole YTD window a day early
|
||||
// and dropped 31 December from it entirely.
|
||||
const today = todayIso();
|
||||
const year = today.slice(0, 4);
|
||||
const yearStart = `${year}-01-01`;
|
||||
const yearEnd = `${year}-12-31`;
|
||||
const in60Iso = addDaysIso(today, 60);
|
||||
|
||||
const [
|
||||
activeCountRes,
|
||||
@@ -59,9 +58,9 @@ export default async function DashboardPage() {
|
||||
hiresYtdRes,
|
||||
exitsYtdRes,
|
||||
openPositionsRes,
|
||||
fteRowsRes,
|
||||
fteRows,
|
||||
divisionsRes,
|
||||
headcountRowsRes,
|
||||
headcountRows,
|
||||
upcomingHiresRes,
|
||||
upcomingExitsRes,
|
||||
upcomingReturnsRes,
|
||||
@@ -80,27 +79,27 @@ export default async function DashboardPage() {
|
||||
.gte("exit_date", yearStart)
|
||||
.lte("exit_date", yearEnd),
|
||||
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("employees").select("division_id").in("status", ["Aktiv", "Karenz"]),
|
||||
fetchAllRows(() => supabase.from("employees").select("division_id").in("status", ["Aktiv", "Karenz"]).order("id")),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, entry_date")
|
||||
.eq("status", "Geplant")
|
||||
.gte("entry_date", todayIso)
|
||||
.gte("entry_date", today)
|
||||
.lte("entry_date", in60Iso),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, exit_date")
|
||||
.not("exit_date", "is", null)
|
||||
.gte("exit_date", todayIso)
|
||||
.gte("exit_date", today)
|
||||
.lte("exit_date", in60Iso),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, karenz_return_date")
|
||||
.eq("status", "Karenz")
|
||||
.not("karenz_return_date", "is", null)
|
||||
.gte("karenz_return_date", todayIso)
|
||||
.gte("karenz_return_date", today)
|
||||
.lte("karenz_return_date", in60Iso),
|
||||
supabase
|
||||
.from("employee_history")
|
||||
@@ -110,10 +109,10 @@ export default async function DashboardPage() {
|
||||
.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>();
|
||||
for (const row of headcountRowsRes.data ?? []) {
|
||||
for (const row of headcountRows) {
|
||||
if (!row.division_id) continue;
|
||||
headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
|
||||
}
|
||||
|
||||
@@ -1,73 +1,21 @@
|
||||
import { PositionsPageClient } from "@/components/positions/PositionsPageClient";
|
||||
import { daysBetween } from "@/lib/format";
|
||||
import { daysBetweenIso, toIsoDate } from "@/lib/format";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export default async function PositionsPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
const [openPositions, { data: divisions }, { data: departments }, { data: teams }, { data: activeEmployees }, { data: leads }] =
|
||||
await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("divisions").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"),
|
||||
]);
|
||||
// Teams are only needed for the "Position ausschreiben" dialog's team
|
||||
// select. The division/department/team headcount overview this page used
|
||||
// to render was dropped, and with it the two employee-wide aggregation
|
||||
// queries that fed it.
|
||||
const [openPositions, { data: teams }] = await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("teams").select("*").order("name"),
|
||||
]);
|
||||
|
||||
const teamStats = new Map<string, { headcount: number; fte: number }>();
|
||||
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 openPositionsWithDays = openPositions.map((p) => ({ ...p, daysOpen: daysBetweenIso(toIsoDate(p.created_at)) }));
|
||||
|
||||
const divisionHeadByDivision = new Map<string, { id: string; name: string }>();
|
||||
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 ?? []} />;
|
||||
return <PositionsPageClient openPositions={openPositionsWithDays} teams={teams ?? []} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { Suspense } from "react";
|
||||
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 { createClient } from "@/lib/supabase/server";
|
||||
import type { HistoryEventType } from "@/lib/supabase/types";
|
||||
|
||||
type SearchParams = {
|
||||
mode?: string;
|
||||
@@ -23,7 +36,7 @@ type SearchParams = {
|
||||
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams;
|
||||
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 user = userRes.user;
|
||||
@@ -32,12 +45,20 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
: { data: [] };
|
||||
|
||||
if (mode === "events") {
|
||||
const group = (params.group as EventGroupDimension) || "event_type";
|
||||
const split = (params.split as EventGroupDimension) || undefined;
|
||||
const eventType = (params.eventType as HistoryEventType) || undefined;
|
||||
const group = parseEventGroupDimension(params.group);
|
||||
const split = parseEventSplitDimension(params.split);
|
||||
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 rows = aggregateEvents(events, group, split ?? null, lookups);
|
||||
const events = await loadEventHistory(supabase, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
from,
|
||||
to,
|
||||
});
|
||||
const rows = aggregateEvents(events, group, split, lookups);
|
||||
const total = sumValues(rows);
|
||||
|
||||
return (
|
||||
@@ -47,7 +68,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
eventGroup={group}
|
||||
eventSplit={split ?? ""}
|
||||
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}
|
||||
total={total}
|
||||
recordCount={events.length}
|
||||
@@ -59,10 +80,10 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
);
|
||||
}
|
||||
|
||||
const measure = (params.measure as Measure) || "headcount";
|
||||
const group = (params.group as GroupDimension) || "division";
|
||||
const split = (params.split as GroupDimension) || undefined;
|
||||
const asOf = params.asOf || undefined;
|
||||
const measure = parseMeasure(params.measure);
|
||||
const group = parseGroupDimension(params.group);
|
||||
const split = parseSplitDimension(params.split);
|
||||
const asOf = parseIsoDateParam(params.asOf);
|
||||
|
||||
const employees = await loadSnapshotEmployees(supabase, {
|
||||
division: params.division,
|
||||
@@ -71,7 +92,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
employment: params.employment,
|
||||
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);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
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 = {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
};
|
||||
|
||||
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 (
|
||||
<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 && (
|
||||
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
{error}
|
||||
<form action={logout} className="mt-2">
|
||||
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
|
||||
Abmelden und mit anderem Konto versuchen
|
||||
</button>
|
||||
</form>
|
||||
{code === "no_hr_access" && (
|
||||
<form action={logout} className="mt-2">
|
||||
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
|
||||
Abmelden und mit anderem Konto versuchen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||
import { deriveStatusAsOf, parseStatuses, type OrgLookups } from "@/lib/reports";
|
||||
import { loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
||||
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
|
||||
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
||||
import { requireHrUser } from "@/lib/supabase/auth";
|
||||
import { fetchAllRows } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import type { Database, EmploymentType } from "@/lib/supabase/types";
|
||||
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
|
||||
|
||||
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.
|
||||
export async function GET(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
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 });
|
||||
}
|
||||
const denied = await requireHrUser(supabase);
|
||||
if (denied) return denied;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||
const asOf = params.get("asOf") ?? undefined;
|
||||
const asOf = parseIsoDateParam(params.get("asOf"));
|
||||
const filters: ReportFilters = {
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
@@ -36,25 +31,25 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const statuses = parseStatuses(filters.status);
|
||||
|
||||
let query = supabase.from("employees").select("*").order("last_name");
|
||||
if (filters.division) query = query.eq("division_id", filters.division);
|
||||
if (filters.location) query = query.eq("location_id", filters.location);
|
||||
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
||||
if (!asOf) query = query.in("status", statuses);
|
||||
|
||||
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 });
|
||||
function employeeQuery() {
|
||||
let query = supabase.from("employees").select("*").order("last_name").order("id");
|
||||
if (filters.division) query = query.eq("division_id", filters.division);
|
||||
if (filters.location) query = query.eq("location_id", filters.location);
|
||||
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
||||
if (!asOf) query = query.in("status", statuses);
|
||||
return query;
|
||||
}
|
||||
|
||||
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, asOf);
|
||||
const [employees, { lookups }, allEmployees, dependentsCounts] = await Promise.all([
|
||||
fetchAllRows(employeeQuery),
|
||||
loadOrgLookups(supabase),
|
||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
|
||||
loadDependentsCounts(supabase),
|
||||
]);
|
||||
|
||||
const 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 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) });
|
||||
}
|
||||
|
||||
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>[] = [
|
||||
{ header: "Pers.-Nr.", get: (e) => e.personnel_number },
|
||||
{ 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: "Staatsbürgerschaft", get: (e) => e.nationality },
|
||||
{ header: "Adresse", get: (e) => e.address },
|
||||
{ header: "Postleitzahl", get: (e) => e.postal_code },
|
||||
{ header: "Ort", get: (e) => e.city },
|
||||
{ header: "Wohnsitzland", get: (e) => e.address_country },
|
||||
{ header: "E-Mail", get: (e) => e.email },
|
||||
{ header: "Telefon", get: (e) => e.phone },
|
||||
@@ -86,8 +90,17 @@ function employeeExportColumns(lookups: OrgLookups, managerName: Map<string, str
|
||||
{ header: "Org-Level", get: (e) => e.org_level },
|
||||
{ header: "Beschäftigungsausmaß", get: (e) => e.employment_type },
|
||||
{ header: "Wochenstunden", get: (e) => e.weekly_hours },
|
||||
// work_days is stored in click order (see RoleEmploymentFields), not
|
||||
// guaranteed chronological — re-sort Mo→So for the export.
|
||||
{ header: "Arbeitstage", get: (e) => [...e.work_days].sort((a, b) => WEEKDAY_ORDER.indexOf(a as Weekday) - WEEKDAY_ORDER.indexOf(b as Weekday)).join(", ") },
|
||||
{ header: "Vertragsart", get: (e) => e.contract_type },
|
||||
{ header: "Befristet bis", get: (e) => e.contract_end_date, kind: "date" },
|
||||
{ header: "Angestellte:r / Arbeiter:in", get: (e) => e.worker_type },
|
||||
{ header: "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: "Herkunft", get: (e) => e.source },
|
||||
{ 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: "Karenzbeginn", get: (e) => e.karenz_start_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) {
|
||||
const statusIndex = columns.findIndex((c) => c.header === "Status");
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
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 { requireHrUser } from "@/lib/supabase/auth";
|
||||
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
|
||||
// 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).
|
||||
export async function GET(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
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 });
|
||||
}
|
||||
const denied = await requireHrUser(supabase);
|
||||
if (denied) return denied;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||
const eventType = (params.get("eventType") as HistoryEventType) || undefined;
|
||||
const from = params.get("from") ?? undefined;
|
||||
const to = params.get("to") ?? undefined;
|
||||
const eventType = parseEventType(params.get("eventType"));
|
||||
|
||||
const [{ lookups }, events] = await Promise.all([
|
||||
loadOrgLookups(supabase),
|
||||
loadEventHistory(supabase, {
|
||||
eventType,
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
from,
|
||||
to,
|
||||
from: parseEventDateParam(params.get("from")),
|
||||
to: parseEventDateParam(params.get("to")),
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -6,6 +6,16 @@ import {
|
||||
EVENT_GROUP_LABELS,
|
||||
GROUP_LABELS,
|
||||
MEASURE_LABELS,
|
||||
parseEventDateParam,
|
||||
parseEventGroupDimension,
|
||||
parseEventSplitDimension,
|
||||
parseEventType,
|
||||
parseGroupDimension,
|
||||
parseIsoDateParam,
|
||||
parseMeasure,
|
||||
parseMode,
|
||||
parseSplitDimension,
|
||||
sortKeysForDimension,
|
||||
sumValues,
|
||||
totalForRows,
|
||||
type EventGroupDimension,
|
||||
@@ -14,8 +24,8 @@ import {
|
||||
type ReportRow,
|
||||
} from "@/lib/reports";
|
||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||
import { requireHrUser } from "@/lib/supabase/auth";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import type { HistoryEventType } from "@/lib/supabase/types";
|
||||
|
||||
// Exports exactly the pivot table currently on screen (same mode/measure or
|
||||
// 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.
|
||||
export async function GET(request: NextRequest) {
|
||||
const supabase = await createClient();
|
||||
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 });
|
||||
}
|
||||
const denied = await requireHrUser(supabase);
|
||||
if (denied) return denied;
|
||||
|
||||
const params = request.nextUrl.searchParams;
|
||||
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);
|
||||
|
||||
let rows: ReportRow[];
|
||||
@@ -43,24 +46,24 @@ export async function GET(request: NextRequest) {
|
||||
let filenameBase: string;
|
||||
|
||||
if (mode === "events") {
|
||||
const group = (params.get("group") as EventGroupDimension) || "event_type";
|
||||
const split = (params.get("split") as EventGroupDimension) || null;
|
||||
const eventType = (params.get("eventType") as HistoryEventType) || undefined;
|
||||
const group = parseEventGroupDimension(params.get("group"));
|
||||
const split = parseEventSplitDimension(params.get("split"));
|
||||
const eventType = parseEventType(params.get("eventType"));
|
||||
const events = await loadEventHistory(supabase, {
|
||||
eventType,
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
from: params.get("from") ?? undefined,
|
||||
to: params.get("to") ?? undefined,
|
||||
from: parseEventDateParam(params.get("from")),
|
||||
to: parseEventDateParam(params.get("to")),
|
||||
});
|
||||
rows = aggregateEvents(events, group, split, lookups);
|
||||
columns = eventReportColumns(rows, group, split, sumValues(rows));
|
||||
filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`;
|
||||
} else {
|
||||
const measure = (params.get("measure") as Measure) || "headcount";
|
||||
const group = (params.get("group") as GroupDimension) || "division";
|
||||
const split = (params.get("split") as GroupDimension) || null;
|
||||
const asOf = params.get("asOf") ?? undefined;
|
||||
const measure = parseMeasure(params.get("measure"));
|
||||
const group = parseGroupDimension(params.get("group"));
|
||||
const split = parseSplitDimension(params.get("split"));
|
||||
const asOf = parseIsoDateParam(params.get("asOf"));
|
||||
const employees = await loadSnapshotEmployees(supabase, {
|
||||
division: params.get("division") ?? undefined,
|
||||
location: params.get("location") ?? undefined,
|
||||
@@ -89,7 +92,7 @@ function snapshotReportColumns(
|
||||
): ExportColumn<ReportRow>[] {
|
||||
const columns: ExportColumn<ReportRow>[] = [{ header: GROUP_LABELS[group], get: (r) => r.key }];
|
||||
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) {
|
||||
columns.push({ header: key, get: (r) => Math.round((r.split?.find((s) => s.key === key)?.value ?? 0) * 100) / 100 });
|
||||
}
|
||||
|
||||
@@ -42,4 +42,96 @@
|
||||
body {
|
||||
background-color: var(--color-surface);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Nunito } from "next/font/google";
|
||||
import { ToastProvider } from "@/components/ui/Toast";
|
||||
import "./globals.css";
|
||||
@@ -12,6 +12,18 @@ const nunito = Nunito({
|
||||
export const metadata: Metadata = {
|
||||
title: "Alpenwerk HR",
|
||||
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({
|
||||
@@ -21,7 +33,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
144
components/employees/AddDependentModal.tsx
Normal file
144
components/employees/AddDependentModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
components/employees/AngehoerigeSection.tsx
Normal file
96
components/employees/AngehoerigeSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
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 { DatenAendernPanel } from "./panels/DatenAendernPanel";
|
||||
import { KarenzPanel } from "./panels/KarenzPanel";
|
||||
@@ -14,6 +14,7 @@ import { RehirePanel } from "./panels/RehirePanel";
|
||||
import { TerminatePanel } from "./panels/TerminatePanel";
|
||||
import { TransferPanel } from "./panels/TransferPanel";
|
||||
import { HistorieTab } from "./tabs/HistorieTab";
|
||||
import { NotizenTab } from "./tabs/NotizenTab";
|
||||
import { OrganisationTab } from "./tabs/OrganisationTab";
|
||||
import { StammdatenTab } from "./tabs/StammdatenTab";
|
||||
import { VertragTab } from "./tabs/VertragTab";
|
||||
@@ -24,6 +25,8 @@ type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||
type Dependent = Database["public"]["Tables"]["employee_dependents"]["Row"];
|
||||
type NoteRow = Database["public"]["Tables"]["employee_notes"]["Row"];
|
||||
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
|
||||
type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean };
|
||||
|
||||
@@ -32,6 +35,8 @@ type EmployeeDetailProps = {
|
||||
manager: MiniEmployee | null;
|
||||
directReports: MiniEmployee[];
|
||||
history: HistoryRow[];
|
||||
dependents: Dependent[];
|
||||
notes: NoteRow[];
|
||||
divisions: Division[];
|
||||
departments: Department[];
|
||||
teams: Team[];
|
||||
@@ -40,10 +45,10 @@ type EmployeeDetailProps = {
|
||||
};
|
||||
|
||||
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) {
|
||||
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 [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 isActive = employee.status === "Aktiv" || employee.status === "Karenz";
|
||||
const canEditData = employee.status !== "Ausgetreten";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -68,7 +74,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<StatusChip status={employee.status} entryDate={employee.entry_date} />
|
||||
</div>
|
||||
@@ -87,15 +93,17 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
<ActionButton icon={ArrowRightLeft} label="Versetzen" onClick={() => setPanel("transfer")} />
|
||||
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
|
||||
<ActionButton icon={Clock} label={employee.status === "Karenz" ? "Karenz verwalten" : "Karenz"} onClick={() => setPanel("karenz")} />
|
||||
<ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<XCircle className="h-4 w-4" /> Austritt
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canEditData && <ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />}
|
||||
{isActive && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<XCircle className="h-4 w-4" /> Austritt
|
||||
</button>
|
||||
)}
|
||||
{employee.status === "Ausgetreten" && (
|
||||
<button
|
||||
onClick={() => setPanel("rehire")}
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
{t === "HR-Notizen" ? `HR-Notizen ${notes.length}` : t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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 === "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 === "HR-Notizen" && <NotizenTab employeeId={employee.id} notes={notes} />}
|
||||
</div>
|
||||
|
||||
<TransferPanel
|
||||
@@ -140,7 +151,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
/>
|
||||
<PromotePanel open={panel === "promote"} 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} />
|
||||
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
|
||||
</div>
|
||||
|
||||
89
components/employees/RoleEmploymentFields.tsx
Normal file
89
components/employees/RoleEmploymentFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
components/employees/TitleFields.tsx
Normal file
24
components/employees/TitleFields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -3,27 +3,48 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
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 { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { UN_COUNTRIES } from "@/lib/countries";
|
||||
import { fmtFullName, todayIso } from "@/lib/format";
|
||||
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
|
||||
|
||||
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 router = useRouter();
|
||||
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 [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 [birthDate, setBirthDate] = useState(employee.birth_date);
|
||||
const [svNummer, setSvNummer] = useState(employee.sv_nummer ?? "");
|
||||
const [nationality, setNationality] = useState(employee.nationality);
|
||||
const [address, setAddress] = useState(employee.address ?? "");
|
||||
const [postalCode, setPostalCode] = useState(employee.postal_code ?? "");
|
||||
const [city, setCity] = useState(employee.city ?? "");
|
||||
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
|
||||
const [email, setEmail] = useState(employee.email);
|
||||
const [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 [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) {
|
||||
setEmploymentType(value);
|
||||
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");
|
||||
return;
|
||||
}
|
||||
if (role.workDays.length === 0) {
|
||||
showToast("Mindestens ein Arbeitstag muss ausgewählt sein.", "error");
|
||||
return;
|
||||
}
|
||||
if (!effectiveDate) {
|
||||
showToast("Bitte ein Wirksam-ab-Datum angeben.", "error");
|
||||
return;
|
||||
@@ -58,11 +96,15 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
|
||||
person: {
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
title_prefix: titles.titlePrefix,
|
||||
title_suffix: titles.titleSuffix,
|
||||
gender,
|
||||
birth_date: birthDate,
|
||||
sv_nummer: svNummer,
|
||||
nationality,
|
||||
address,
|
||||
postal_code: postalCode,
|
||||
city,
|
||||
address_country: addressCountry,
|
||||
email,
|
||||
phone,
|
||||
@@ -73,6 +115,15 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
|
||||
contract_type: contractType,
|
||||
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);
|
||||
if (result.success) {
|
||||
@@ -89,7 +140,7 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
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={
|
||||
<>
|
||||
<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>
|
||||
<h3 className="mb-3 text-sm font-bold text-ink">Person</h3>
|
||||
<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>
|
||||
<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" />
|
||||
@@ -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" />
|
||||
</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>
|
||||
<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">
|
||||
@@ -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>
|
||||
<CountryPicker value={nationality} onChange={setNationality} countries={UN_COUNTRIES} placeholder="Staatsbürgerschaft suchen…" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<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" />
|
||||
</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">Adresse</label>
|
||||
<input value={address} onChange={(e) => setAddress(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||
<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">Land</label>
|
||||
<CountryPicker value={addressCountry} onChange={setAddressCountry} countries={UN_COUNTRIES} placeholder="Land suchen…" />
|
||||
<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>
|
||||
<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…" />
|
||||
</div>
|
||||
<div>
|
||||
<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" />
|
||||
@@ -225,6 +285,13 @@ export function DatenAendernPanel({ open, onClose, employee }: { open: boolean;
|
||||
)}
|
||||
</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>
|
||||
</SlideOver>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||
|
||||
export function HistorieTab({ history }: { history: HistoryRow[] }) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const today = todayIso();
|
||||
|
||||
if (history.length === 0) {
|
||||
return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>;
|
||||
|
||||
134
components/employees/tabs/NotizenTab.tsx
Normal file
134
components/employees/tabs/NotizenTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,34 @@
|
||||
import { Network } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
|
||||
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
|
||||
|
||||
type OrganisationTabProps = {
|
||||
employeeId: string;
|
||||
manager: MiniEmployee | null;
|
||||
directReports: MiniEmployee[];
|
||||
breadcrumb: string;
|
||||
};
|
||||
|
||||
export function OrganisationTab({ manager, directReports, breadcrumb }: OrganisationTabProps) {
|
||||
export function OrganisationTab({ employeeId, manager, directReports, breadcrumb }: OrganisationTabProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
|
||||
<p className="mt-1 text-sm text-ink">{breadcrumb}</p>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
|
||||
<p className="mt-1 text-sm text-ink">{breadcrumb}</p>
|
||||
</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>
|
||||
|
||||
@@ -1,28 +1,44 @@
|
||||
import { AngehoerigeSection } from "@/components/employees/AngehoerigeSection";
|
||||
import { fmtAge, fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["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][] = [
|
||||
["Titel", titles.length > 0 ? titles.join(", ") : "–"],
|
||||
["Geburtsdatum", `${fmtDate(employee.birth_date)} (${fmtAge(employee.birth_date)} Jahre)`],
|
||||
["SV-Nummer", employee.sv_nummer ?? "–"],
|
||||
["Staatsbürgerschaft", employee.nationality],
|
||||
["E-Mail", employee.email],
|
||||
["Telefon", employee.phone ?? "–"],
|
||||
["Standort", location ? `${location.name} (${location.country})` : "–"],
|
||||
["Adresse", employee.address ?? "–"],
|
||||
["Adresse", formatAddress(employee)],
|
||||
["Land", employee.address_country ?? "–"],
|
||||
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
|
||||
];
|
||||
return (
|
||||
<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{label}</dt>
|
||||
<dd className="mt-1 text-sm text-ink">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<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">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{label}</dt>
|
||||
<dd className="mt-1 text-sm text-ink">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<AngehoerigeSection employeeId={employee.id} dependents={dependents} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,14 +13,23 @@ const PAYGRADE_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||
const flags = [
|
||||
employee.is_betriebsrat && "Betriebsrat",
|
||||
employee.has_dienstwagen && "Dienstwagen",
|
||||
employee.is_laterale_fuehrung && "Laterale Führung",
|
||||
employee.is_c_level && "C-Level",
|
||||
].filter(Boolean);
|
||||
const rows: [string, string][] = [
|
||||
["Eintrittsdatum", fmtDate(employee.entry_date)],
|
||||
["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],
|
||||
["Wochenstunden", `${employee.weekly_hours} h`],
|
||||
["Urlaubsanspruch", "25 Tage"],
|
||||
["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)]);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
const stepValid = [
|
||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId),
|
||||
Boolean(draft.positionId && draft.besetzung),
|
||||
Boolean(draft.entryDate),
|
||||
Boolean(draft.entryDate && draft.workDays.length > 0),
|
||||
true,
|
||||
][step];
|
||||
|
||||
@@ -72,6 +72,8 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
const result = await hireEmployee({
|
||||
first_name: draft.firstName,
|
||||
last_name: draft.lastName,
|
||||
title_prefix: draft.titlePrefix,
|
||||
title_suffix: draft.titleSuffix,
|
||||
gender: draft.gender,
|
||||
birth_date: draft.birthDate,
|
||||
sv_nummer: draft.svNummer || undefined,
|
||||
@@ -85,6 +87,13 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
weekly_hours: Number(draft.weeklyHours),
|
||||
paygrade: draft.paygrade,
|
||||
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);
|
||||
if (result.success) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TitleFields } from "@/components/employees/TitleFields";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
type StepPersonProps = {
|
||||
@@ -9,7 +10,7 @@ type StepPersonProps = {
|
||||
export function StepPerson({ draft, update, locations }: StepPersonProps) {
|
||||
return (
|
||||
<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>
|
||||
<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" />
|
||||
@@ -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" />
|
||||
</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>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Geschlecht*</label>
|
||||
<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>
|
||||
<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 className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<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" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { X } from "lucide-react";
|
||||
import { Lookup } from "@/components/ui/Lookup";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
@@ -42,6 +43,7 @@ export function StepPosition({ draft, update, openPositions }: StepPositionProps
|
||||
<div className="text-xs text-ink-muted">
|
||||
{selected.position_number} · {selected.orgLabel}
|
||||
</div>
|
||||
<div className="text-xs text-ink-muted">Gültig ab {fmtDate(selected.valid_from)}</div>
|
||||
</div>
|
||||
<button type="button" onClick={() => update({ positionId: "" })} aria-label="Auswahl aufheben">
|
||||
<X className="h-4 w-4 text-ink-muted" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { fmtDate, fmtFullName } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
@@ -19,8 +19,14 @@ type StepSummaryProps = {
|
||||
|
||||
export function StepSummary({ draft, selectedPosition, locations }: StepSummaryProps) {
|
||||
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][] = [
|
||||
["Name", `${draft.firstName} ${draft.lastName}`],
|
||||
["Name", fmtFullName(draft.firstName, draft.lastName, draft.titlePrefix, draft.titleSuffix)],
|
||||
["Geschlecht", draft.gender === "m" ? "männlich" : "weiblich"],
|
||||
["Geburtsdatum", fmtDate(draft.birthDate)],
|
||||
["SV-Nummer", draft.svNummer || "–"],
|
||||
@@ -35,11 +41,15 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
||||
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
|
||||
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
|
||||
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
|
||||
["Angestellte:r / Arbeiter:in", draft.workerType],
|
||||
["Kollektivvertrag", draft.collectiveAgreement],
|
||||
["Arbeitstage", draft.workDays.join(", ") || "–"],
|
||||
["Merkmale", flags.length > 0 ? flags.join(", ") : "–"],
|
||||
];
|
||||
|
||||
return (
|
||||
<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]) => (
|
||||
<div key={label}>
|
||||
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{label}</dt>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { RoleEmploymentFields } from "@/components/employees/RoleEmploymentFields";
|
||||
import type { PaygradeType } from "@/lib/supabase/types";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
@@ -20,7 +21,7 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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" />
|
||||
@@ -48,7 +49,7 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Beschäftigungsausmaß</label>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 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 = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
titlePrefix: string[];
|
||||
titleSuffix: string[];
|
||||
gender: GenderType;
|
||||
birthDate: string;
|
||||
svNummer: string;
|
||||
@@ -20,11 +22,20 @@ export type HireDraftData = {
|
||||
employmentType: EmploymentType;
|
||||
weeklyHours: string;
|
||||
paygrade: PaygradeType;
|
||||
workerType: WorkerType;
|
||||
collectiveAgreement: CollectiveAgreement;
|
||||
workDays: Weekday[];
|
||||
isBetriebsrat: boolean;
|
||||
hasDienstwagen: boolean;
|
||||
isLateraleFuehrung: boolean;
|
||||
isCLevel: boolean;
|
||||
};
|
||||
|
||||
export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
titlePrefix: [],
|
||||
titleSuffix: [],
|
||||
gender: "m",
|
||||
birthDate: "",
|
||||
svNummer: "",
|
||||
@@ -39,4 +50,11 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
employmentType: "Vollzeit",
|
||||
weeklyHours: "38.5",
|
||||
paygrade: "B",
|
||||
workerType: "Angestellte:r",
|
||||
collectiveAgreement: "Handel",
|
||||
workDays: ["Mo", "Di", "Mi", "Do", "Fr"],
|
||||
isBetriebsrat: false,
|
||||
hasDienstwagen: false,
|
||||
isLateraleFuehrung: false,
|
||||
isCLevel: false,
|
||||
};
|
||||
|
||||
76
components/orgchart/AsOfPicker.tsx
Normal file
76
components/orgchart/AsOfPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,21 @@
|
||||
|
||||
import { ChevronDown, ChevronRight, Search } from "lucide-react";
|
||||
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 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[] }) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
type ViewMode = "list" | "graph";
|
||||
|
||||
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 [mode, setMode] = useState<ViewMode>("list");
|
||||
const focusRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { childrenByManager, totalReportsById, root } = useMemo(() => {
|
||||
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));
|
||||
|
||||
// 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>();
|
||||
function countTotal(id: string): number {
|
||||
if (totals.has(id)) return totals.get(id)!;
|
||||
const direct = byManager.get(id) ?? [];
|
||||
let total = direct.length;
|
||||
for (const child of direct) total += countTotal(child.id);
|
||||
function countTotal(id: string, path: Set<string>): number {
|
||||
const memo = totals.get(id);
|
||||
if (memo !== undefined) return memo;
|
||||
if (path.has(id)) return 0;
|
||||
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);
|
||||
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__") ?? [] };
|
||||
}, [employees]);
|
||||
|
||||
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 matches = new Set<string>();
|
||||
for (const e of employees) {
|
||||
@@ -47,7 +66,14 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
}
|
||||
}
|
||||
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(() => {
|
||||
if (!matchIds) return null;
|
||||
@@ -55,7 +81,7 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
const toExpand = new Set<string>();
|
||||
for (const id of matchIds) {
|
||||
let current = byId.get(id);
|
||||
while (current?.manager_id) {
|
||||
while (current?.manager_id && !toExpand.has(current.manager_id)) {
|
||||
toExpand.add(current.manager_id);
|
||||
current = byId.get(current.manager_id);
|
||||
}
|
||||
@@ -63,21 +89,42 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
return toExpand;
|
||||
}, [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) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
function isExpanded(id: string): boolean {
|
||||
return expanded.has(id) || (ancestorExpandIds?.has(id) ?? false);
|
||||
}
|
||||
const isExpanded = useCallback((id: string): boolean => expanded.has(id) || (ancestorExpandIds?.has(id) ?? false), [expanded, ancestorExpandIds]);
|
||||
|
||||
function renderNode(e: OrgEmployee, depth: number) {
|
||||
const children = childrenByManager.get(e.id) ?? [];
|
||||
const chartTree = useMemo<ChartNode[]>(() => {
|
||||
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 expandedNow = isExpanded(e.id);
|
||||
const isMatch = matchIds?.has(e.id) ?? false;
|
||||
@@ -86,6 +133,7 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
return (
|
||||
<div key={e.id}>
|
||||
<div
|
||||
ref={e.id === focusId ? focusRef : undefined}
|
||||
className={`flex items-center gap-2 rounded px-2 py-1.5 hover:bg-surface ${isMatch ? "bg-brand-100" : ""}`}
|
||||
style={{ paddingLeft: depth * 24 + 8 }}
|
||||
>
|
||||
@@ -109,7 +157,9 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -140,8 +190,13 @@ export function EmployeeTree({ employees }: { employees: OrgEmployee[] }) {
|
||||
>
|
||||
Alles einklappen
|
||||
</button>
|
||||
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
139
components/orgchart/GraphOrgChart.tsx
Normal file
139
components/orgchart/GraphOrgChart.tsx
Normal 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 } };
|
||||
11
components/orgchart/LazyGraphOrgChart.tsx
Normal file
11
components/orgchart/LazyGraphOrgChart.tsx
Normal 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>,
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import { AsOfPicker } from "./AsOfPicker";
|
||||
import { EmployeeTree } from "./EmployeeTree";
|
||||
import { PositionTree } from "./PositionTree";
|
||||
import { ReorgWorkbench } from "./ReorgWorkbench";
|
||||
@@ -17,10 +18,29 @@ type OrgChartClientProps = {
|
||||
teams: OrgTeam[];
|
||||
openPositions: OpenPositionResolved[];
|
||||
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 isToday = asOf === today;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -33,13 +53,31 @@ export function OrgChartClient({ employees, divisions, departments, teams, openP
|
||||
{ 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" && (
|
||||
<PositionTree employees={employees} divisions={divisions} departments={departments} teams={teams} openPositions={openPositions} />
|
||||
)}
|
||||
{view === "reo" && (
|
||||
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
|
||||
)}
|
||||
{view === "reo" &&
|
||||
(isToday ? (
|
||||
<ReorgWorkbench employees={employees} divisions={divisions} departments={departments} teams={teams} reorgScenarios={reorgScenarios} />
|
||||
) : (
|
||||
// A reorg planned against a past or projected roster would be
|
||||
// applied to the *live* org anyway — better to send the user back
|
||||
// to today than to let them assemble moves from a roster that is
|
||||
// not the one the change would hit.
|
||||
<div className="rounded border border-border bg-white p-6 text-sm text-ink-body">
|
||||
<p className="font-semibold text-ink">Reorganisation nur zum heutigen Stand</p>
|
||||
<p className="mt-1 text-ink-muted">
|
||||
Es ist ein abweichender Stichtag gewählt. Reorganisationen wirken immer auf die aktuelle Struktur — wechseln
|
||||
Sie zurück auf „Heute“, um eine zu planen.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
122
components/orgchart/OrgChartNode.tsx
Normal file
122
components/orgchart/OrgChartNode.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
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 { 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({
|
||||
depth,
|
||||
@@ -43,38 +47,134 @@ type PositionTreeProps = {
|
||||
|
||||
export function PositionTree({ employees, divisions, departments, teams, openPositions }: PositionTreeProps) {
|
||||
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) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isExpanded = useCallback((id: string) => expanded.has(id), [expanded]);
|
||||
|
||||
const ceo = employees.find((e) => e.org_level === 0) ?? null;
|
||||
const divisionHeadByDivision = new Map<string, OrgEmployee>();
|
||||
const teamLeadByTeam = new Map<string, OrgEmployee>();
|
||||
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
|
||||
for (const e of employees) {
|
||||
if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
|
||||
if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
|
||||
if (!e.is_lead && e.org_level === 3 && e.team_id) {
|
||||
if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
|
||||
const byTitle = icsByTeamAndTitle.get(e.team_id)!;
|
||||
if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []);
|
||||
byTitle.get(e.job_title)!.push(e);
|
||||
const { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle } = useMemo(() => {
|
||||
const divisionHeadByDivision = new Map<string, OrgEmployee>();
|
||||
const teamLeadByTeam = new Map<string, OrgEmployee>();
|
||||
const icsByTeamAndTitle = new Map<string, Map<string, OrgEmployee[]>>();
|
||||
for (const e of employees) {
|
||||
if (e.org_level === 1 && e.division_id) divisionHeadByDivision.set(e.division_id, e);
|
||||
if (e.is_lead && e.team_id) teamLeadByTeam.set(e.team_id, e);
|
||||
if (!e.is_lead && e.org_level === 3 && e.team_id) {
|
||||
if (!icsByTeamAndTitle.has(e.team_id)) icsByTeamAndTitle.set(e.team_id, new Map());
|
||||
const byTitle = icsByTeamAndTitle.get(e.team_id)!;
|
||||
if (!byTitle.has(e.job_title)) byTitle.set(e.job_title, []);
|
||||
byTitle.get(e.job_title)!.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
const openByTeam = new Map<string, OpenPositionResolved[]>();
|
||||
for (const p of openPositions) {
|
||||
if (!openByTeam.has(p.team_id)) openByTeam.set(p.team_id, []);
|
||||
openByTeam.get(p.team_id)!.push(p);
|
||||
}
|
||||
return { divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle };
|
||||
}, [employees]);
|
||||
const openByTeam = useMemo(() => {
|
||||
const map = new Map<string, OpenPositionResolved[]>();
|
||||
for (const p of openPositions) {
|
||||
if (!map.has(p.team_id)) map.set(p.team_id, []);
|
||||
map.get(p.team_id)!.push(p);
|
||||
}
|
||||
return map;
|
||||
}, [openPositions]);
|
||||
|
||||
// Mirrors the JSX walk below into the generic ChartNode shape for graph
|
||||
// mode — same synthetic ids ("root", div-*, dept-*, team-*, teamKey-title)
|
||||
// the list already uses as `expanded` keys, so one Set drives both.
|
||||
const chartTree = useMemo<ChartNode[]>(() => {
|
||||
if (!ceo) return [];
|
||||
|
||||
function buildTeam(team: OrgTeam): ChartNode {
|
||||
const teamKey = `team-${team.id}`;
|
||||
const lead = teamLeadByTeam.get(team.id);
|
||||
const icsByTitle = icsByTeamAndTitle.get(team.id) ?? new Map<string, OrgEmployee[]>();
|
||||
const openForTeam = openByTeam.get(team.id) ?? [];
|
||||
|
||||
const titleGroups: ChartNode[] = Array.from(icsByTitle.entries()).map(([title, people]) => ({
|
||||
id: `${teamKey}-${title}`,
|
||||
kind: "group",
|
||||
label: title,
|
||||
sublabel: `${people.length}x besetzt`,
|
||||
children: people.map((p) => ({
|
||||
id: p.id,
|
||||
kind: "person",
|
||||
label: `${p.first_name} ${p.last_name}`,
|
||||
href: `/employees/${p.id}`,
|
||||
avatar: { firstName: p.first_name, lastName: p.last_name },
|
||||
children: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
const vacancyNodes: ChartNode[] = openForTeam.map((p) => ({
|
||||
id: `vac-${p.id}`,
|
||||
kind: "vacancy",
|
||||
label: `${p.position_number} · ${p.title}`,
|
||||
href: "/positions",
|
||||
children: [],
|
||||
}));
|
||||
|
||||
return {
|
||||
id: teamKey,
|
||||
kind: "role",
|
||||
label: `Teamleitung ${team.name}`,
|
||||
sublabel: lead ? `besetzt: ${lead.first_name} ${lead.last_name}` : "vakant",
|
||||
vacant: !lead,
|
||||
children: [...titleGroups, ...vacancyNodes],
|
||||
};
|
||||
}
|
||||
|
||||
function buildDept(dept: OrgDepartment): ChartNode {
|
||||
return {
|
||||
id: `dept-${dept.id}`,
|
||||
kind: "group",
|
||||
label: `${dept.org_number} · ${dept.name}`,
|
||||
children: teams.filter((t) => t.department_id === dept.id).map(buildTeam),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDivision(div: OrgDivision): ChartNode {
|
||||
const head = divisionHeadByDivision.get(div.id);
|
||||
return {
|
||||
id: `div-${div.id}`,
|
||||
kind: "role",
|
||||
label: `Bereichsleitung ${div.name}`,
|
||||
sublabel: head ? `besetzt: ${head.first_name} ${head.last_name}` : "vakant",
|
||||
vacant: !head,
|
||||
children: departments.filter((d) => d.division_id === div.id).map(buildDept),
|
||||
};
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: "root",
|
||||
kind: "role",
|
||||
label: "Geschäftsführung",
|
||||
sublabel: `besetzt: ${ceo.first_name} ${ceo.last_name}`,
|
||||
children: divisions.map(buildDivision),
|
||||
},
|
||||
];
|
||||
}, [ceo, divisionHeadByDivision, teamLeadByTeam, icsByTeamAndTitle, openByTeam, divisions, departments, teams]);
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex justify-end">
|
||||
<SegmentedControl<ViewMode> value={mode} onChange={setMode} options={[{ value: "list", label: "Liste" }, { value: "graph", label: "Grafisch" }]} />
|
||||
</div>
|
||||
{mode === "graph" ? (
|
||||
<LazyGraphOrgChart tree={chartTree} isExpanded={isExpanded} onToggle={toggle} />
|
||||
) : (
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
{ceo && (
|
||||
<TreeRow depth={0} expandable expandedNow={expanded.has("root")} onToggle={() => toggle("root")}>
|
||||
<span className="text-sm font-semibold text-ink">Geschäftsführung</span>
|
||||
@@ -168,6 +268,8 @@ export function PositionTree({ employees, divisions, departments, teams, openPos
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ export function ReorgWorkbench({ employees, divisions, departments, teams, reorg
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded border border-border bg-white p-4">
|
||||
<h2 className="mb-3 text-sm font-bold text-ink">Neue Reorganisation</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<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" />
|
||||
|
||||
60
components/orgchart/graphLayout.ts
Normal file
60
components/orgchart/graphLayout.ts
Normal 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;
|
||||
}
|
||||
@@ -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 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 };
|
||||
|
||||
// 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[];
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createPosition, searchSuperiors, type SuperiorSearchResult } from "@/ac
|
||||
import { Lookup } from "@/components/ui/Lookup";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
@@ -17,6 +18,7 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
|
||||
const [isLead, setIsLead] = useState(false);
|
||||
const [superior, setSuperior] = useState<SuperiorSearchResult | null>(null);
|
||||
const [teamId, setTeamId] = useState("");
|
||||
const [validFrom, setValidFrom] = useState(todayIso);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
function reset() {
|
||||
@@ -24,10 +26,11 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
|
||||
setIsLead(false);
|
||||
setSuperior(null);
|
||||
setTeamId("");
|
||||
setValidFrom(todayIso());
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title || !superior || (isLead && !teamId)) {
|
||||
if (!title || !superior || !validFrom || (isLead && !teamId)) {
|
||||
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||
return;
|
||||
}
|
||||
@@ -37,6 +40,7 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
|
||||
superior_employee_id: superior.id,
|
||||
is_lead: isLead,
|
||||
team_id: isLead ? teamId : undefined,
|
||||
valid_from: validFrom,
|
||||
});
|
||||
setPending(false);
|
||||
if (result.success) {
|
||||
@@ -130,6 +134,15 @@ export function CreatePositionModal({ open, onClose, teams }: { open: boolean; o
|
||||
</select>
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -1,53 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 { CreatePositionModal } from "./CreatePositionModal";
|
||||
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 PositionsPageClientProps = {
|
||||
openPositions: OpenPositionWithDays[];
|
||||
divisionCards: DivisionCard[];
|
||||
teams: { id: string; org_number: string; name: string; department_id: string }[];
|
||||
};
|
||||
|
||||
export function PositionsPageClient({ openPositions, divisionCards, teams }: PositionsPageClientProps) {
|
||||
const { openWizard } = useHireWizard();
|
||||
export function PositionsPageClient({ openPositions, teams }: PositionsPageClientProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
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>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{openPositions.map((p) => (
|
||||
<div key={p.id} className="rounded border border-border p-3">
|
||||
<div className="text-sm font-semibold text-ink">{p.title}</div>
|
||||
<div className="text-xs text-ink-muted">
|
||||
{p.position_number} · {p.orgLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
{openPositions.map((p) => {
|
||||
const notYetValid = p.valid_from > today;
|
||||
return (
|
||||
<div key={p.id} className="flex flex-col rounded border border-border p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-ink">{p.title}</div>
|
||||
<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">
|
||||
{p.position_number} · {p.orgLabel}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-ink-muted">seit {p.daysOpen} Tagen offen</div>
|
||||
{notYetValid && <div className="mt-1 text-xs font-semibold text-warning-text">Gültig ab {fmtDate(p.valid_from)}</div>}
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</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
|
||||
Besetzen
|
||||
</button>
|
||||
</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} />
|
||||
{staffTarget && (
|
||||
<StaffInternallyModal
|
||||
|
||||
@@ -28,7 +28,7 @@ export function StaffInternallyModal({ open, onClose, positionId, positionTitle,
|
||||
const result = await staffPositionInternally({ position_id: positionId, employee_id: employee.id });
|
||||
setPending(false);
|
||||
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();
|
||||
onClose();
|
||||
setEmployee(null);
|
||||
@@ -41,7 +41,7 @@ export function StaffInternallyModal({ open, onClose, positionId, positionTitle,
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Intern besetzen"
|
||||
title="Position besetzen"
|
||||
footer={
|
||||
<>
|
||||
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
MEASURE_LABELS,
|
||||
parseStatuses,
|
||||
REPORT_PRESETS,
|
||||
sortKeysForDimension,
|
||||
STATUS_OPTIONS,
|
||||
todayIso,
|
||||
type EventGroupDimension,
|
||||
@@ -86,11 +87,11 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
if (v) sp.set(k, v);
|
||||
else sp.delete(k);
|
||||
}
|
||||
router.push(`${pathname}?${sp.toString()}`);
|
||||
router.push(`${pathname}?${sp.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
function switchMode(next: "snapshot" | "events") {
|
||||
router.push(`${pathname}?mode=${next}`);
|
||||
router.push(`${pathname}?mode=${next}`, { scroll: false });
|
||||
}
|
||||
|
||||
function toggleStatus(status: string) {
|
||||
@@ -106,7 +107,7 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
sp.set("group", preset.group);
|
||||
if (preset.split) sp.set("split", preset.split);
|
||||
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>) {
|
||||
@@ -115,7 +116,7 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
if (typeof v === "string" && v) sp.set(k, v);
|
||||
}
|
||||
if (!sp.get("mode")) sp.set("mode", "snapshot");
|
||||
router.push(`${pathname}?${sp.toString()}`);
|
||||
router.push(`${pathname}?${sp.toString()}`, { scroll: false });
|
||||
}
|
||||
|
||||
async function handleConfirmSaveReport() {
|
||||
@@ -189,6 +190,9 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
|
||||
const isAverage = mode === "snapshot" && AVERAGE_MEASURES.includes(props.measure);
|
||||
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 statusExportLabel = selectedStatuses.length === STATUS_OPTIONS.length ? "Alle" : selectedStatuses.join(", ");
|
||||
const currentYear = new Date().getFullYear();
|
||||
@@ -508,10 +512,15 @@ export function ReportsPageClient(props: ReportsPageClientProps) {
|
||||
</div>
|
||||
|
||||
<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)) && (
|
||||
<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 className={`h-2.5 w-2.5 rounded-full ${SPLIT_COLORS[i % SPLIT_COLORS.length]}`} />
|
||||
{key}
|
||||
|
||||
26
components/shell/AppShell.tsx
Normal file
26
components/shell/AppShell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
components/shell/NotesBell.tsx
Normal file
89
components/shell/NotesBell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,91 @@
|
||||
"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 { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "Übersicht", icon: LayoutGrid },
|
||||
{ href: "/employees", label: "Mitarbeiter:innen", icon: Users },
|
||||
{ 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: "/audit", label: "Audit-Log", icon: History },
|
||||
] 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();
|
||||
|
||||
// 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 (
|
||||
<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">
|
||||
<span className="text-base font-extrabold text-brand-700">Alpenwerk HR</span>
|
||||
</div>
|
||||
<ul className="flex-1 space-y-1 px-3 py-4">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
|
||||
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
return (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex items-center gap-3 rounded px-3 py-2 text-sm font-semibold transition-colors ${
|
||||
active ? "bg-brand-100 text-brand-700" : "text-ink-body hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
<>
|
||||
<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>
|
||||
<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>
|
||||
{/* 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 }) => {
|
||||
const active = isActiveRoute(pathname, href);
|
||||
return (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { LogOut } from "lucide-react";
|
||||
import { LogOut, Menu } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { logout } from "@/actions/auth";
|
||||
import type { OpenNote } from "@/lib/notes";
|
||||
import { NewHireButton } from "./NewHireButton";
|
||||
import { NotesBell } from "./NotesBell";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
"/": "Übersicht",
|
||||
"/employees": "Mitarbeiter:innen",
|
||||
"/orgchart": "Organigramm",
|
||||
"/positions": "Positionen & Bereiche",
|
||||
"/positions": "Positionen",
|
||||
"/reports": "Berichte",
|
||||
"/audit": "Audit-Log",
|
||||
};
|
||||
@@ -22,20 +24,37 @@ function titleFor(pathname: string): string {
|
||||
|
||||
type TopbarProps = {
|
||||
userLabel: string;
|
||||
openNotes: OpenNote[];
|
||||
onOpenNav: () => void;
|
||||
};
|
||||
|
||||
export function Topbar({ userLabel }: TopbarProps) {
|
||||
export function Topbar({ userLabel, openNotes, onOpenNav }: TopbarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<header className="flex h-14 items-center justify-between border-b border-border bg-white px-6">
|
||||
<h1 className="text-base font-bold text-ink">{titleFor(pathname)}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
// Sticky, with the notch inset added to its top padding: on an iPhone in
|
||||
// landscape the bar would otherwise sit under the rounded corner.
|
||||
<header className="sticky top-0 z-20 flex h-14 shrink-0 items-center justify-between border-b border-border bg-white px-3 pt-[env(safe-area-inset-top)] sm:px-6">
|
||||
<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 />
|
||||
<div className="flex items-center gap-2 border-l border-border pl-4 text-sm">
|
||||
<span className="font-semibold text-ink">{userLabel}</span>
|
||||
<div className="flex items-center gap-2 text-sm sm:border-l sm:border-border sm:pl-4">
|
||||
{/* The name is the first thing worth dropping on a narrow screen —
|
||||
the account is still reachable via the sign-out control. */}
|
||||
<span className="hidden font-semibold text-ink md:inline">{userLabel}</span>
|
||||
<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" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -25,21 +25,27 @@ export function Modal({ open, onClose, title, children, footer, widthClassName =
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className={`flex max-h-[85vh] w-full flex-col rounded bg-white shadow-xl ${widthClassName}`}>
|
||||
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||
// Bottom-aligned on a phone (thumb reach, and it clears the keyboard when
|
||||
// a field is focused), centred from sm up.
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-0 sm:items-center sm:p-4">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className={`flex max-h-[92dvh] w-full flex-col rounded-t-xl bg-white shadow-xl sm:max-h-[85dvh] sm:rounded ${widthClassName}`}
|
||||
>
|
||||
<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="rounded p-1 text-ink-muted hover:bg-surface"
|
||||
>
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
|
||||
{footer && <div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">{footer}</div>}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">{children}</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>
|
||||
);
|
||||
|
||||
57
components/ui/Pagination.tsx
Normal file
57
components/ui/Pagination.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
components/ui/Picklist.tsx
Normal file
60
components/ui/Picklist.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -29,26 +29,28 @@ export function SlideOver({ open, onClose, title, subtitle, children, footer }:
|
||||
onClick={onClose}
|
||||
/>
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between border-b border-border px-6 py-4">
|
||||
<div>
|
||||
<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 className="min-w-0">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
className="rounded p-1 text-ink-muted hover:bg-surface"
|
||||
>
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
|
||||
{footer && <div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">{footer}</div>}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-4 sm:px-6">{children}</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>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"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 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);
|
||||
}, []);
|
||||
|
||||
// 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 (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
|
||||
31
docker-compose.yml
Normal file
31
docker-compose.yml
Normal 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
96
docs/data-model.md
Normal 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
113
docs/security-review.md
Normal 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).
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EmploymentStatus } from "./supabase/types";
|
||||
import type { EmploymentStatus, NoteCategory } from "./supabase/types";
|
||||
|
||||
const AVATAR_PALETTE = [
|
||||
"#d6046e",
|
||||
@@ -30,7 +30,7 @@ export const STATUS_STYLES: Record<EmploymentStatus, string> = {
|
||||
|
||||
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",
|
||||
danger: "bg-danger-bg text-danger-text",
|
||||
warning: "bg-warning-bg text-warning-text",
|
||||
@@ -61,3 +61,11 @@ const ACTION_CATEGORY: Record<string, ColorCategory> = {
|
||||
export function actionBadgeStyle(action: string): string {
|
||||
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",
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ExcelJS from "exceljs";
|
||||
import { todayIso } from "./format";
|
||||
|
||||
// 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
|
||||
@@ -38,8 +39,12 @@ export function toCsv<T>(rows: T[], columns: ExportColumn<T>[]): string {
|
||||
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 {
|
||||
const d = new Date(`${value}T00:00:00`);
|
||||
const d = new Date(`${value}T00:00:00Z`);
|
||||
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 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,
|
||||
key: c.header,
|
||||
key: String(i),
|
||||
width: Math.min(40, Math.max(12, c.header.length + 4)),
|
||||
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) {
|
||||
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);
|
||||
record[c.header] =
|
||||
record[String(i)] =
|
||||
c.kind === "date" && typeof value === "string" && value
|
||||
? (parseIsoDate(value) ?? value)
|
||||
: typeof value === "string"
|
||||
@@ -75,9 +83,18 @@ export async function toXlsx<T>(rows: T[], columns: ExportColumn<T>[], sheetName
|
||||
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 {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
return `${base}-${today}.${format}`;
|
||||
const slug = base
|
||||
.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 {
|
||||
|
||||
@@ -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",
|
||||
month: "2-digit",
|
||||
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 {
|
||||
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;
|
||||
if (Number.isNaN(d.getTime())) return "–";
|
||||
return dateFormatter.format(d);
|
||||
return dateTimeFormatter.format(d);
|
||||
}
|
||||
|
||||
export function initials(firstName: string, lastName: string): string {
|
||||
@@ -17,24 +56,37 @@ export function initials(firstName: string, lastName: string): string {
|
||||
return `${a}${b}`;
|
||||
}
|
||||
|
||||
// "Dr. Max Mustermann, MSc MBA" — prefix titles precede the name, suffix
|
||||
// titles follow after a comma, both space-joined in the stored order.
|
||||
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 {
|
||||
const d = typeof birthDate === "string" ? new Date(birthDate) : birthDate;
|
||||
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;
|
||||
return yearsBetweenIso(toIsoDate(birthDate), todayIso());
|
||||
}
|
||||
|
||||
export function tenure(entryDate: string | Date, endDate?: string | Date | null): string {
|
||||
const start = typeof entryDate === "string" ? new Date(entryDate) : entryDate;
|
||||
const end = endDate ? (typeof endDate === "string" ? new Date(endDate) : endDate) : new Date();
|
||||
const start = toIsoDate(entryDate);
|
||||
const end = endDate ? toIsoDate(endDate) : todayIso();
|
||||
|
||||
let years = end.getFullYear() - start.getFullYear();
|
||||
let months = end.getMonth() - start.getMonth();
|
||||
if (end.getDate() < start.getDate()) months -= 1;
|
||||
let years = Number(end.slice(0, 4)) - Number(start.slice(0, 4));
|
||||
let months = Number(end.slice(5, 7)) - Number(start.slice(5, 7));
|
||||
if (Number(end.slice(8, 10)) < Number(start.slice(8, 10))) months -= 1;
|
||||
if (months < 0) {
|
||||
years -= 1;
|
||||
months += 12;
|
||||
@@ -47,8 +99,16 @@ export function tenure(entryDate: string | Date, endDate?: string | Date | null)
|
||||
return yearPart || monthPart || "unter 1 Monat";
|
||||
}
|
||||
|
||||
export function daysBetween(a: string | Date, b: string | Date = new Date()): number {
|
||||
const start = typeof a === "string" ? new Date(a) : a;
|
||||
const end = typeof b === "string" ? new Date(b) : b;
|
||||
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
// Anchored at UTC midnight on both sides so the difference is a whole number
|
||||
// of calendar days regardless of DST transitions in between.
|
||||
export function daysBetweenIso(from: string, to: string = todayIso()): number {
|
||||
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
26
lib/notes.ts
Normal 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
212
lib/orgchart-data.ts
Normal 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 };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type OpenPositionResolved = {
|
||||
division_id: string;
|
||||
is_lead: boolean;
|
||||
reports_to_employee_id: string | null;
|
||||
valid_from: string;
|
||||
created_at: string;
|
||||
managerName: string | null;
|
||||
orgLabel: string;
|
||||
@@ -20,7 +21,7 @@ export async function loadOpenPositions(supabase: SupabaseClient<Database>): Pro
|
||||
const orgMaps = await loadOrgMaps(supabase);
|
||||
const { data: positions } = await supabase
|
||||
.from("positions")
|
||||
.select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, created_at")
|
||||
.select("id, position_number, title, team_id, division_id, is_lead, reports_to_employee_id, valid_from, created_at")
|
||||
.eq("status", "open")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
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";
|
||||
|
||||
// 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 =
|
||||
"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`
|
||||
// (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[]> {
|
||||
const asOf = filters.asOf || todayIso();
|
||||
|
||||
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS);
|
||||
if (filters.division) query = query.eq("division_id", filters.division);
|
||||
if (filters.location) query = query.eq("location_id", filters.location);
|
||||
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
||||
function snapshotQuery() {
|
||||
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id");
|
||||
if (filters.division) query = query.eq("division_id", filters.division);
|
||||
if (filters.location) query = query.eq("location_id", filters.location);
|
||||
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
||||
return query;
|
||||
}
|
||||
|
||||
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,
|
||||
first_name: e.first_name,
|
||||
last_name: e.last_name,
|
||||
@@ -74,6 +90,14 @@ export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>,
|
||||
paygrade: e.paygrade,
|
||||
birth_date: e.birth_date,
|
||||
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);
|
||||
@@ -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 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");
|
||||
if (from) historyQuery = historyQuery.gte("event_date", from);
|
||||
if (to) historyQuery = historyQuery.lte("event_date", to);
|
||||
if (filters.eventType) historyQuery = historyQuery.eq("event_type", filters.eventType);
|
||||
function historyQuery() {
|
||||
let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id");
|
||||
if (from) query = query.gte("event_date", from);
|
||||
if (to) query = query.lte("event_date", to);
|
||||
if (filters.eventType) query = query.eq("event_type", filters.eventType);
|
||||
return query;
|
||||
}
|
||||
|
||||
const [{ data: history }, { data: employees }] = await Promise.all([
|
||||
historyQuery,
|
||||
supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id"),
|
||||
const [history, employees] = await Promise.all([
|
||||
fetchAllRows(historyQuery),
|
||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, division_id, team_id, location_id").order("id")),
|
||||
]);
|
||||
|
||||
const employeeById = new Map((employees ?? []).map((e) => [e.id, e]));
|
||||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
||||
const events: ReportEvent[] = [];
|
||||
for (const h of history ?? []) {
|
||||
for (const h of history) {
|
||||
const emp = employeeById.get(h.employee_id);
|
||||
if (!emp) continue;
|
||||
if (filters.division && emp.division_id !== filters.division) continue;
|
||||
|
||||
191
lib/reports.ts
191
lib/reports.ts
@@ -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) ──────────────────────────────
|
||||
|
||||
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 =
|
||||
| "division"
|
||||
@@ -14,7 +17,15 @@ export type GroupDimension =
|
||||
| "contract_type"
|
||||
| "entry_year"
|
||||
| "source"
|
||||
| "paygrade";
|
||||
| "paygrade"
|
||||
| "worker_type"
|
||||
| "collective_agreement"
|
||||
| "betriebsrat"
|
||||
| "dienstwagen"
|
||||
| "laterale_fuehrung"
|
||||
| "c_level"
|
||||
| "has_dependents"
|
||||
| "weekday";
|
||||
|
||||
export const MEASURE_LABELS: Record<Measure, string> = {
|
||||
headcount: "Headcount",
|
||||
@@ -23,6 +34,7 @@ export const MEASURE_LABELS: Record<Measure, string> = {
|
||||
avg_age: "Ø Alter",
|
||||
avg_tenure: "Ø Zugehörigkeit",
|
||||
female_share: "Frauenanteil",
|
||||
avg_dependents: "Ø Angehörige",
|
||||
};
|
||||
|
||||
export const GROUP_LABELS: Record<GroupDimension, string> = {
|
||||
@@ -36,9 +48,17 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
|
||||
entry_year: "Eintrittsjahr",
|
||||
source: "Intern/Extern",
|
||||
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"];
|
||||
|
||||
export const STATUS_OPTIONS: EmploymentStatus[] = ["Aktiv", "Karenz", "Geplant", "Ausgetreten"];
|
||||
@@ -74,6 +94,14 @@ export type ReportEmployee = {
|
||||
paygrade: string;
|
||||
birth_date: 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 = {
|
||||
@@ -83,10 +111,6 @@ export type OrgLookups = {
|
||||
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
|
||||
// timeline (entry/exit/Karenz), rather than trusting `employees.status`,
|
||||
// which only ever reflects *today*. Division/team/location still reflect the
|
||||
@@ -104,18 +128,10 @@ export function deriveStatusAsOf(
|
||||
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 {
|
||||
const start = new Date(entryDate);
|
||||
const end = exitDate && exitDate <= asOf ? new Date(exitDate) : new Date(asOf);
|
||||
return Math.max(0, (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 365.25));
|
||||
const start = Date.parse(`${entryDate}T00:00:00Z`);
|
||||
const end = Date.parse(`${exitDate && exitDate <= asOf ? exitDate : asOf}T00:00:00Z`);
|
||||
return Math.max(0, (end - start) / (1000 * 60 * 60 * 24 * 365.25));
|
||||
}
|
||||
|
||||
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":
|
||||
return e.contract_type;
|
||||
case "entry_year":
|
||||
return String(new Date(e.entry_date).getFullYear());
|
||||
return e.entry_date.slice(0, 4);
|
||||
case "source":
|
||||
return e.source;
|
||||
case "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:
|
||||
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 {
|
||||
if (rows.length === 0) return 0;
|
||||
switch (measure) {
|
||||
@@ -155,11 +217,13 @@ export function measureValue(rows: ReportEmployee[], measure: Measure, asOf: str
|
||||
case "parttime_rate":
|
||||
return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100;
|
||||
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":
|
||||
return rows.reduce((s, e) => s + tenureYearsAsOf(e.entry_date, e.exit_date, asOf), 0) / rows.length;
|
||||
case "female_share":
|
||||
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:
|
||||
return 0;
|
||||
}
|
||||
@@ -179,9 +243,10 @@ export function aggregateReport(
|
||||
): ReportRow[] {
|
||||
const byGroup = new Map<string, ReportEmployee[]>();
|
||||
for (const e of employees) {
|
||||
const key = groupKeyFor(e, group, lookups);
|
||||
if (!byGroup.has(key)) byGroup.set(key, []);
|
||||
byGroup.get(key)!.push(e);
|
||||
for (const key of groupKeysFor(e, group, lookups)) {
|
||||
if (!byGroup.has(key)) byGroup.set(key, []);
|
||||
byGroup.get(key)!.push(e);
|
||||
}
|
||||
}
|
||||
|
||||
const rows: ReportRow[] = [];
|
||||
@@ -198,19 +263,21 @@ export function aggregateReport(
|
||||
if (split) {
|
||||
const bySplit = new Map<string, ReportEmployee[]>();
|
||||
for (const e of rowsForGroup) {
|
||||
const sKey = groupKeyFor(e, split, lookups);
|
||||
if (!bySplit.has(sKey)) bySplit.set(sKey, []);
|
||||
bySplit.get(sKey)!.push(e);
|
||||
for (const sKey of groupKeysFor(e, split, lookups)) {
|
||||
if (!bySplit.has(sKey)) bySplit.set(sKey, []);
|
||||
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,
|
||||
value: measureValue(sRows, measure, asOf),
|
||||
count: sRows.length,
|
||||
}));
|
||||
row.split = split === "weekday" ? sortByWeekday(splitRows) : splitRows;
|
||||
}
|
||||
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 {
|
||||
@@ -231,6 +298,9 @@ export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimen
|
||||
{ name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" },
|
||||
{ name: "Headcount nach Paygrade", measure: "headcount", group: "paygrade" },
|
||||
{ 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) ─────────────────────────────
|
||||
@@ -295,7 +365,7 @@ function eventGroupKeyFor(e: ReportEvent, dim: EventGroupDimension, lookups: Org
|
||||
case "location":
|
||||
return lookups.locationName.get(e.location_id) ?? "Unbekannt";
|
||||
case "event_year":
|
||||
return String(new Date(e.event_date).getFullYear());
|
||||
return e.event_date.slice(0, 4);
|
||||
default:
|
||||
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: "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);
|
||||
}
|
||||
|
||||
@@ -7,9 +7,17 @@ import type { Database } from "./types";
|
||||
// "server-only" import makes an accidental client-side import a build error
|
||||
// instead of a runtime one.
|
||||
export function createAdminClient() {
|
||||
return createSupabaseClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||
{ auth: { autoRefreshToken: false, persistSession: false } }
|
||||
);
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
||||
}
|
||||
if (!serviceRoleKey) {
|
||||
throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY");
|
||||
}
|
||||
|
||||
return createSupabaseClient<Database>(supabaseUrl, serviceRoleKey, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
}
|
||||
|
||||
21
lib/supabase/auth.ts
Normal file
21
lib/supabase/auth.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Database } from "./types";
|
||||
|
||||
// Route Handlers under /api/export/* are outside the App Router layout tree,
|
||||
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
|
||||
// re-establish that the caller is an active HR user itself. RLS is still the
|
||||
// real boundary (an unauthorized session simply reads nothing); this exists
|
||||
// so those routes answer 401/403 instead of handing back an empty workbook.
|
||||
export async function requireHrUser(supabase: SupabaseClient<Database>): Promise<NextResponse | null> {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||
|
||||
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
|
||||
if (profile?.role !== "hr" || profile.is_active !== true) {
|
||||
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -3,9 +3,17 @@ import type { Database } from "./types";
|
||||
|
||||
// For use in Client Components that need interactivity (filters, live
|
||||
// hints, etc). Server Components/Actions should use lib/supabase/server.ts.
|
||||
// Only ever reads NEXT_PUBLIC_* vars — this file is bundled for the browser.
|
||||
export function createClient() {
|
||||
return createBrowserClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
);
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
||||
}
|
||||
if (!supabaseAnonKey) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY");
|
||||
}
|
||||
|
||||
return createBrowserClient<Database>(supabaseUrl, supabaseAnonKey);
|
||||
}
|
||||
|
||||
@@ -7,3 +7,27 @@
|
||||
export function sanitizeIlikeTerm(term: string): string {
|
||||
return term.replace(/[,()]/g, "");
|
||||
}
|
||||
|
||||
// PostgREST caps every response at db.max_rows (1000, see
|
||||
// supabase/config.toml) and does so *silently* — a query over ~800 employees
|
||||
// or the employee_history log just stops returning rows, and a report or
|
||||
// export built from it is quietly wrong rather than failing. Anything that
|
||||
// aggregates a whole table has to page explicitly; anything that renders a
|
||||
// bounded list (an employee page, the audit log) uses .range() directly and
|
||||
// does not need this.
|
||||
const PAGE_SIZE = 1000;
|
||||
|
||||
type PagedQuery<Row> = {
|
||||
range: (from: number, to: number) => PromiseLike<{ data: Row[] | null; error: unknown }>;
|
||||
};
|
||||
|
||||
export async function fetchAllRows<Row>(buildQuery: () => PagedQuery<Row>): Promise<Row[]> {
|
||||
const rows: Row[] = [];
|
||||
for (let page = 0; ; page++) {
|
||||
const { data, error } = await buildQuery().range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1);
|
||||
if (error || !data) break;
|
||||
rows.push(...data);
|
||||
if (data.length < PAGE_SIZE) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
import "server-only";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { cookies } from "next/headers";
|
||||
import type { Database } from "./types";
|
||||
|
||||
// For use in Server Components and Server Actions. Respects the signed-in
|
||||
// user's session, so all reads/writes go through RLS as that user.
|
||||
// user's session, so all reads/writes go through RLS as that user. Uses only
|
||||
// the anon key (never the service role key) — the user's own session cookie
|
||||
// is what determines access, via RLS.
|
||||
export async function createClient() {
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
||||
}
|
||||
if (!supabaseAnonKey) {
|
||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_ANON_KEY");
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
|
||||
return createServerClient<Database>(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {
|
||||
// Called from a Server Component during render — safe to ignore
|
||||
// because proxy.ts refreshes the session cookie on every request.
|
||||
}
|
||||
},
|
||||
return createServerClient<Database>(supabaseUrl, supabaseAnonKey, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll();
|
||||
},
|
||||
}
|
||||
);
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
);
|
||||
} catch {
|
||||
// Called from a Server Component during render — safe to ignore
|
||||
// because proxy.ts refreshes the session cookie on every request.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ export type ContractType = "unbefristet" | "befristet";
|
||||
export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F";
|
||||
export type SourceType = "Intern" | "Extern";
|
||||
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
|
||||
// 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.
|
||||
@@ -107,6 +112,8 @@ export type Database = {
|
||||
sv_nummer: string | null;
|
||||
nationality: string;
|
||||
address: string | null;
|
||||
postal_code: string | null;
|
||||
city: string | null;
|
||||
address_country: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
@@ -132,6 +139,15 @@ export type Database = {
|
||||
karenz_start_date: string | null;
|
||||
karenz_return_date: 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;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -144,6 +160,8 @@ export type Database = {
|
||||
sv_nummer?: string | null;
|
||||
nationality?: string;
|
||||
address?: string | null;
|
||||
postal_code?: string | null;
|
||||
city?: string | null;
|
||||
address_country?: string | null;
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
@@ -167,6 +185,15 @@ export type Database = {
|
||||
karenz_start_date?: string | null;
|
||||
karenz_return_date?: 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;
|
||||
updated_at?: string;
|
||||
};
|
||||
@@ -193,6 +220,58 @@ export type Database = {
|
||||
};
|
||||
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 & {
|
||||
Row: {
|
||||
id: string;
|
||||
@@ -203,6 +282,7 @@ export type Database = {
|
||||
is_lead: boolean;
|
||||
reports_to_employee_id: string | null;
|
||||
status: PositionStatus;
|
||||
valid_from: string;
|
||||
created_at: string;
|
||||
filled_at: string | null;
|
||||
filled_by_employee_id: string | null;
|
||||
@@ -216,6 +296,7 @@ export type Database = {
|
||||
is_lead?: boolean;
|
||||
reports_to_employee_id?: string | null;
|
||||
status?: PositionStatus;
|
||||
valid_from?: string;
|
||||
created_at?: string;
|
||||
filled_at?: string | null;
|
||||
filled_by_employee_id?: string | null;
|
||||
@@ -310,6 +391,25 @@ export type Database = {
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
|
||||
};
|
||||
// Written exclusively by trg_track_employee_assignment; RLS grants HR
|
||||
// read access only, hence no Insert/Update shapes worth modelling.
|
||||
employee_assignments: NoRelationships & {
|
||||
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>;
|
||||
Functions: {
|
||||
@@ -322,7 +422,12 @@ export type Database = {
|
||||
record_karenz_return: { 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 };
|
||||
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 };
|
||||
delete_position: { 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 };
|
||||
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
|
||||
8
lib/titles.ts
Normal file
8
lib/titles.ts
Normal 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"];
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
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,
|
||||
// browser feature access). No Content-Security-Policy yet: this app has no
|
||||
// inventory of its script/style/connect sources, and shipping a guessed
|
||||
|
||||
263
package-lock.json
generated
263
package-lock.json
generated
@@ -8,8 +8,10 @@
|
||||
"name": "alpenwerk-hr",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@supabase/ssr": "^0.12.1",
|
||||
"@supabase/supabase-js": "^2.110.5",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"next": "16.2.10",
|
||||
@@ -295,6 +297,21 @@
|
||||
"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": {
|
||||
"version": "0.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz",
|
||||
@@ -2372,6 +2389,55 @@
|
||||
"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": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -2414,7 +2480,7 @@
|
||||
"version": "19.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -2424,7 +2490,7 @@
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -3171,6 +3237,48 @@
|
||||
"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": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
@@ -3877,6 +3985,12 @@
|
||||
"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": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
@@ -3994,9 +4108,114 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -7424,7 +7643,6 @@
|
||||
"version": "8.5.19",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
|
||||
"integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -8823,6 +9041,15 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -9254,6 +9481,34 @@
|
||||
"peerDependencies": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@
|
||||
"check": "npm run lint && npm run typecheck && npm run test && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^3.0.0",
|
||||
"@supabase/ssr": "^0.12.1",
|
||||
"@supabase/supabase-js": "^2.110.5",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"lucide-react": "^1.24.0",
|
||||
"next": "16.2.10",
|
||||
|
||||
2
proxy.ts
2
proxy.ts
@@ -52,7 +52,7 @@ export async function proxy(request: NextRequest) {
|
||||
if (isLoginRoute) return response;
|
||||
const url = request.nextUrl.clone();
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
202
supabase/migrations/20260715120000_split_address_fields.sql
Normal file
202
supabase/migrations/20260715120000_split_address_fields.sql
Normal 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;
|
||||
@@ -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;
|
||||
$$;
|
||||
309
supabase/migrations/20260718120000_role_employment_fields.sql
Normal file
309
supabase/migrations/20260718120000_role_employment_fields.sql
Normal 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;
|
||||
78
supabase/migrations/20260718140000_employee_dependents.sql
Normal file
78
supabase/migrations/20260718140000_employee_dependents.sql
Normal 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;
|
||||
$$;
|
||||
321
supabase/migrations/20260718150000_person_titles.sql
Normal file
321
supabase/migrations/20260718150000_person_titles.sql
Normal 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;
|
||||
@@ -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;
|
||||
107
supabase/migrations/20260719120000_employee_notes.sql
Normal file
107
supabase/migrations/20260719120000_employee_notes.sql
Normal 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;
|
||||
$$;
|
||||
@@ -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;
|
||||
@@ -319,6 +319,8 @@ type EmployeeRow = {
|
||||
sv_nummer: string;
|
||||
nationality: string;
|
||||
address: string;
|
||||
postal_code: string;
|
||||
city: string;
|
||||
address_country: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
@@ -399,7 +401,9 @@ function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId
|
||||
last_name: lastName,
|
||||
gender,
|
||||
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),
|
||||
email: makeEmail(firstName, lastName),
|
||||
phone: `+43 664 ${randInt(1000000, 9999999)}`,
|
||||
|
||||
140
tests/integration/assignment-history.test.ts
Normal file
140
tests/integration/assignment-history.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
import {
|
||||
adminClient,
|
||||
createHrUser,
|
||||
deleteTestEmployee,
|
||||
deleteTestUser,
|
||||
hireTestEmployee,
|
||||
isoDateOffset,
|
||||
pickSeededTeam,
|
||||
signInAs,
|
||||
type TestUser,
|
||||
} from "./helpers";
|
||||
|
||||
// Org-assignment history (supabase/migrations/20260724120000_employee_
|
||||
// assignment_history.sql). The point of capturing this with a trigger rather
|
||||
// than inside each RPC is that it holds for *every* write path — so these
|
||||
// tests drive the real RPCs and assert on the timeline they leave behind.
|
||||
describe("employee_assignments history", () => {
|
||||
let hrUser: TestUser;
|
||||
let hrClient: SupabaseClient<Database>;
|
||||
let teamA: { id: string };
|
||||
let teamB: { id: string };
|
||||
const employeeIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
teamA = await pickSeededTeam();
|
||||
teamB = await pickSeededTeam(teamA.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
async function freshEmployee(teamId: string): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, teamId);
|
||||
employeeIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function assignmentsFor(employeeId: string) {
|
||||
const { data } = await adminClient
|
||||
.from("employee_assignments")
|
||||
.select("team_id, job_title, valid_from, valid_to")
|
||||
.eq("employee_id", employeeId)
|
||||
.order("valid_from");
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
it("opens an interval when an employee is hired", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].team_id).toBe(teamA.id);
|
||||
expect(rows[0].valid_to).toBeNull();
|
||||
});
|
||||
|
||||
it("closes the old interval and opens a new one on transfer", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const { error } = await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].team_id).toBe(teamA.id);
|
||||
expect(rows[0].valid_to).toBe(isoDateOffset(0));
|
||||
expect(rows[1].team_id).toBe(teamB.id);
|
||||
expect(rows[1].valid_to).toBeNull();
|
||||
// Intervals must abut exactly, or an as-of query lands in a gap.
|
||||
expect(rows[1].valid_from).toBe(rows[0].valid_to);
|
||||
});
|
||||
|
||||
it("rewrites in place rather than leaving a zero-length interval for a same-day second move", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||
});
|
||||
await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamA.id },
|
||||
});
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows.every((r) => r.valid_to === null || r.valid_to > r.valid_from)).toBe(true);
|
||||
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
|
||||
expect(rows.at(-1)?.team_id).toBe(teamA.id);
|
||||
});
|
||||
|
||||
it("records a promotion's new title as its own interval", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const { error } = await hrClient.rpc("promote_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_title: "Senior Testtitel" },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows.at(-1)?.job_title).toBe("Senior Testtitel");
|
||||
expect(rows.at(-1)?.valid_to).toBeNull();
|
||||
});
|
||||
|
||||
it("writes no new interval when nothing about the placement changed", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const before = await assignmentsFor(employeeId);
|
||||
|
||||
const { error } = await hrClient.rpc("change_employee_data", {
|
||||
payload: {
|
||||
employee_id: employeeId,
|
||||
effective_date: isoDateOffset(0),
|
||||
person: { phone: "+43 1 2345678" },
|
||||
contract: {},
|
||||
role: {},
|
||||
},
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
expect(await assignmentsFor(employeeId)).toHaveLength(before.length);
|
||||
});
|
||||
|
||||
it("keeps exactly one open interval per employee", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||
});
|
||||
const rows = await assignmentsFor(employeeId);
|
||||
expect(rows.filter((r) => r.valid_to === null)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("is not readable without an active HR session", async () => {
|
||||
const outsider = await createHrUser({ active: false });
|
||||
const outsiderClient = await signInAs(outsider);
|
||||
const { data } = await outsiderClient.from("employee_assignments").select("id").limit(1);
|
||||
expect(data ?? []).toHaveLength(0);
|
||||
await deleteTestUser(outsider);
|
||||
});
|
||||
});
|
||||
@@ -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("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);
|
||||
}
|
||||
|
||||
156
tests/integration/positions.test.ts
Normal file
156
tests/integration/positions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,25 @@
|
||||
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", () => {
|
||||
it("formats an ISO date string in de-AT order", () => {
|
||||
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", () => {
|
||||
expect(fmtDate(null)).toBe("–");
|
||||
expect(fmtDate(undefined)).toBe("–");
|
||||
@@ -67,12 +81,47 @@ describe("tenure", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("daysBetween", () => {
|
||||
describe("daysBetweenIso", () => {
|
||||
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", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
168
tests/unit/orgchart-data.test.ts
Normal file
168
tests/unit/orgchart-data.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
aggregateReport,
|
||||
deriveStatusAsOf,
|
||||
groupKeyFor,
|
||||
groupKeysFor,
|
||||
measureValue,
|
||||
MEASURE_LABELS,
|
||||
type OrgLookups,
|
||||
@@ -30,6 +31,14 @@ function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
|
||||
paygrade: "B",
|
||||
birth_date: "1990-01-01",
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -71,6 +80,35 @@ describe("groupKeyFor", () => {
|
||||
it("derives entry_year from entry_date", () => {
|
||||
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", () => {
|
||||
@@ -118,6 +156,11 @@ describe("measureValue", () => {
|
||||
];
|
||||
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", () => {
|
||||
@@ -173,6 +216,23 @@ describe("aggregateReport", () => {
|
||||
expect(measureValue(employees, "avg_age", asOf)).toBe(30);
|
||||
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", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user