From 79f0e19bf880097e526eaa4d01ad9cabe59ad3ee Mon Sep 17 00:00:00 2001 From: Maximilian Stubhan Date: Fri, 24 Jul 2026 23:38:10 +0200 Subject: [PATCH] Org assignment history, mobile support, and a correctness pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .dockerignore | 20 ++ .env.example | 6 + .gitignore | 7 + DEPLOYMENT.md | 156 +++++++++ Dockerfile | 48 +++ README.md | 154 +++++++-- actions/auth.ts | 2 +- actions/employees.ts | 47 ++- actions/positions.ts | 37 +- app/(app)/audit/page.tsx | 34 +- app/(app)/employees/[id]/page.tsx | 48 ++- app/(app)/employees/page.tsx | 15 +- app/(app)/layout.tsx | 19 +- app/(app)/orgchart/page.tsx | 77 +++-- app/(app)/page.tsx | 41 ++- app/(app)/positions/page.tsx | 74 +--- app/(app)/reports/page.tsx | 49 ++- app/(auth)/login/page.tsx | 27 +- app/api/export/employees/route.ts | 76 +++-- app/api/export/events/route.ts | 25 +- app/api/export/report/route.ts | 47 +-- app/globals.css | 92 +++++ app/layout.tsx | 16 +- components/employees/AddDependentModal.tsx | 144 ++++++++ components/employees/AngehoerigeSection.tsx | 96 ++++++ components/employees/EmployeeDetail.tsx | 41 ++- components/employees/RoleEmploymentFields.tsx | 89 +++++ components/employees/TitleFields.tsx | 24 ++ .../employees/panels/DatenAendernPanel.tsx | 87 ++++- components/employees/tabs/HistorieTab.tsx | 4 +- components/employees/tabs/NotizenTab.tsx | 134 ++++++++ components/employees/tabs/OrganisationTab.tsx | 22 +- components/employees/tabs/StammdatenTab.tsx | 36 +- components/employees/tabs/VertragTab.tsx | 11 +- components/hire/HireWizard.tsx | 11 +- components/hire/StepPerson.tsx | 8 +- components/hire/StepPosition.tsx | 2 + components/hire/StepSummary.tsx | 16 +- components/hire/StepVertrag.tsx | 10 +- components/hire/types.ts | 20 +- components/orgchart/AsOfPicker.tsx | 76 +++++ components/orgchart/EmployeeTree.tsx | 99 ++++-- components/orgchart/GraphOrgChart.tsx | 139 ++++++++ components/orgchart/LazyGraphOrgChart.tsx | 11 + components/orgchart/OrgChartClient.tsx | 48 ++- components/orgchart/OrgChartNode.tsx | 122 +++++++ components/orgchart/PositionTree.tsx | 146 ++++++-- components/orgchart/ReorgWorkbench.tsx | 2 +- components/orgchart/graphLayout.ts | 60 ++++ components/orgchart/types.ts | 23 ++ components/positions/CreatePositionModal.tsx | 15 +- components/positions/PositionsPageClient.tsx | 157 +++------ components/positions/StaffInternallyModal.tsx | 4 +- components/reports/ReportsPageClient.tsx | 19 +- components/shell/AppShell.tsx | 26 ++ components/shell/NotesBell.tsx | 89 +++++ components/shell/Sidebar.tsx | 99 ++++-- components/shell/Topbar.tsx | 37 +- components/ui/Modal.tsx | 28 +- components/ui/Pagination.tsx | 57 ++++ components/ui/Picklist.tsx | 60 ++++ components/ui/SlideOver.tsx | 26 +- components/ui/Toast.tsx | 9 +- docker-compose.yml | 31 ++ docs/data-model.md | 96 ++++++ docs/security-review.md | 113 ++++++ lib/colors.ts | 12 +- lib/export.ts | 31 +- lib/format.ts | 98 ++++-- lib/notes.ts | 26 ++ lib/orgchart-data.ts | 212 ++++++++++++ lib/positions.ts | 3 +- lib/reports-data.ts | 59 +++- lib/reports.ts | 191 +++++++++-- lib/supabase/admin.ts | 18 +- lib/supabase/auth.ts | 21 ++ lib/supabase/client.ts | 16 +- lib/supabase/query.ts | 24 ++ lib/supabase/server.ts | 51 +-- lib/supabase/types.ts | 105 ++++++ lib/titles.ts | 8 + next.config.ts | 3 + package-lock.json | 263 +++++++++++++- package.json | 2 + proxy.ts | 2 +- .../20260715120000_split_address_fields.sql | 202 +++++++++++ ...716120000_position_validity_and_delete.sql | 183 ++++++++++ .../20260718120000_role_employment_fields.sql | 309 +++++++++++++++++ .../20260718140000_employee_dependents.sql | 78 +++++ .../20260718150000_person_titles.sql | 321 ++++++++++++++++++ ...0718160000_dependents_effective_dating.sql | 214 ++++++++++++ .../20260719120000_employee_notes.sql | 107 ++++++ ...0724120000_employee_assignment_history.sql | 120 +++++++ supabase/seed.ts | 6 +- tests/integration/assignment-history.test.ts | 140 ++++++++ tests/integration/helpers.ts | 26 ++ tests/integration/positions.test.ts | 156 +++++++++ tests/unit/format.test.ts | 57 +++- tests/unit/orgchart-data.test.ts | 168 +++++++++ tests/unit/reports.test.ts | 60 ++++ tests/unit/security.test.ts | 64 +++- 101 files changed, 6120 insertions(+), 700 deletions(-) create mode 100644 .dockerignore create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 components/employees/AddDependentModal.tsx create mode 100644 components/employees/AngehoerigeSection.tsx create mode 100644 components/employees/RoleEmploymentFields.tsx create mode 100644 components/employees/TitleFields.tsx create mode 100644 components/employees/tabs/NotizenTab.tsx create mode 100644 components/orgchart/AsOfPicker.tsx create mode 100644 components/orgchart/GraphOrgChart.tsx create mode 100644 components/orgchart/LazyGraphOrgChart.tsx create mode 100644 components/orgchart/OrgChartNode.tsx create mode 100644 components/orgchart/graphLayout.ts create mode 100644 components/shell/AppShell.tsx create mode 100644 components/shell/NotesBell.tsx create mode 100644 components/ui/Pagination.tsx create mode 100644 components/ui/Picklist.tsx create mode 100644 docker-compose.yml create mode 100644 docs/data-model.md create mode 100644 docs/security-review.md create mode 100644 lib/notes.ts create mode 100644 lib/orgchart-data.ts create mode 100644 lib/supabase/auth.ts create mode 100644 lib/titles.ts create mode 100644 supabase/migrations/20260715120000_split_address_fields.sql create mode 100644 supabase/migrations/20260716120000_position_validity_and_delete.sql create mode 100644 supabase/migrations/20260718120000_role_employment_fields.sql create mode 100644 supabase/migrations/20260718140000_employee_dependents.sql create mode 100644 supabase/migrations/20260718150000_person_titles.sql create mode 100644 supabase/migrations/20260718160000_dependents_effective_dating.sql create mode 100644 supabase/migrations/20260719120000_employee_notes.sql create mode 100644 supabase/migrations/20260724120000_employee_assignment_history.sql create mode 100644 tests/integration/assignment-history.test.ts create mode 100644 tests/integration/positions.test.ts create mode 100644 tests/unit/orgchart-data.test.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7cd7c8c --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index 0fb0ab8..cb52814 100644 --- a/.env.example +++ b/.env.example @@ -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 ` when it diff --git a/.gitignore b/.gitignore index be6aeb8..0a597be 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..3406a30 --- /dev/null +++ b/DEPLOYMENT.md @@ -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 && 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: /:` 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). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..73ed13c --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index e215bc4..d4eb424 100644 --- a/README.md +++ b/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 ` 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. diff --git a/actions/auth.ts b/actions/auth.ts index b264e79..09f3ade 100644 --- a/actions/auth.ts +++ b/actions/auth.ts @@ -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("/"); diff --git a/actions/employees.ts b/actions/employees.ts index bb3a89a..20fe57c 100644 --- a/actions/employees.ts +++ b/actions/employees.ts @@ -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, 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 { 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; contract: Record; + role: Record; }): Promise { 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 { + 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 { + 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 { + return callRpc("add_employee_note", payload, [`/employees/${payload.employee_id}`, "/"]); +} + +export async function completeEmployeeNote(payload: { note_id: string; employee_id: string }): Promise { + 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 { diff --git a/actions/positions.ts b/actions/positions.ts index 9787e0d..f5adc78 100644 --- a/actions/positions.ts +++ b/actions/positions.ts @@ -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, + revalidate: string[] +): Promise { + 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 { - 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 { + return callRpc("delete_position", { position_id: positionId }, POSITION_PATHS); } export async function staffPositionInternally(payload: { position_id: string; employee_id: string; }): Promise { - 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 }; diff --git a/app/(app)/audit/page.tsx b/app/(app)/audit/page.tsx index 9b8d301..8f456b5 100644 --- a/app/(app)/audit/page.tsx +++ b/app/(app)/audit/page.tsx @@ -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 }) { const params = await searchParams; @@ -68,7 +76,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis {(entries ?? []).map((entry) => { return ( - {fmtDateTime(entry.occurred_at)} + {dateTimeFormatter.format(new Date(entry.occurred_at))} {entry.actor_name} {entry.action} @@ -97,19 +105,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis - {totalPages > 1 && ( -
- {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( - - {p} - - ))} -
- )} + pageHref(params, p)} label="Audit-Log" />

Alle Änderungen an Personal-Stammdaten werden automatisch protokolliert und sind unveränderbar. diff --git a/app/(app)/employees/[id]/page.tsx b/app/(app)/employees/[id]/page.tsx index 4669ef9..e679413 100644 --- a/app/(app)/employees/[id]/page.tsx +++ b/app/(app)/employees/[id]/page.tsx @@ -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 ( - {totalPages > 1 && ( -

- {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( - - {p} - - ))} -
- )} + pageHref(params, p)} label="Mitarbeiter:innen" /> ); } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 5736afa..f189f25 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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 ( -
- -
- -
-
{children}
-
-
-
+ + {children} +
); } diff --git a/app/(app)/orgchart/page.tsx b/app/(app)/orgchart/page.tsx index 0f2a6bc..cccb90a 100644 --- a/app/(app)/orgchart/page.tsx +++ b/app/(app)/orgchart/page.tsx @@ -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 }) { + 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 ( - + + + ); } diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 30c36d1..ca65e8c 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -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 = { 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(); - 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); } diff --git a/app/(app)/positions/page.tsx b/app/(app)/positions/page.tsx index 386735c..4404a58 100644 --- a/app/(app)/positions/page.tsx +++ b/app/(app)/positions/page.tsx @@ -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(); - const divisionHeadcount = new Map(); - 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(); - const teamLeadByTeam = new Map(); - 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(); - 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 ; + return ; } diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx index d3b86ae..c914572 100644 --- a/app/(app)/reports/page.tsx +++ b/app/(app)/reports/page.tsx @@ -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 }) { 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 ( diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index 3730d9a..e97ea68 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -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 (
@@ -16,11 +29,13 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { {error && (
{error} -
- -
+ {code === "no_hr_access" && ( +
+ +
+ )}
)} diff --git a/app/api/export/employees/route.ts b/app/api/export/employees/route.ts index e50fddc..b6b9128 100644 --- a/app/api/export/employees/route.ts +++ b/app/api/export/employees/route.ts @@ -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, asOf?: string): ExportColumn[] { +const WEEKDAY_ORDER: Weekday[] = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; + +function employeeExportColumns( + lookups: OrgLookups, + managerName: Map, + dependentsCounts: Map, + asOf?: string +): ExportColumn[] { const columns: ExportColumn[] = [ { 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 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 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 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"); diff --git a/app/api/export/events/route.ts b/app/api/export/events/route.ts index 8322389..3e24266 100644 --- a/app/api/export/events/route.ts +++ b/app/api/export/events/route.ts @@ -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")), }), ]); diff --git a/app/api/export/report/route.ts b/app/api/export/report/route.ts index ea9e610..5e306a6 100644 --- a/app/api/export/report/route.ts +++ b/app/api/export/report/route.ts @@ -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[] { const columns: ExportColumn[] = [{ 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 }); } diff --git a/app/globals.css b/app/globals.css index e43f48e..021d62e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; + } } diff --git a/app/layout.tsx b/app/layout.tsx index 2793da7..425b672 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - + {children} diff --git a/components/employees/AddDependentModal.tsx b/components/employees/AddDependentModal.tsx new file mode 100644 index 0000000..7fcb7fe --- /dev/null +++ b/components/employees/AddDependentModal.tsx @@ -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("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 ( + + + + + } + > +
+
+ + setEffectiveDate(e.target.value)} + className="w-full rounded border border-border px-3 py-2 text-sm" + /> +
+
+
+ + setFirstName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+ + setLastName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+
+ + setSvNummer(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+ + setBirthDate(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" /> +
+
+ + +
+
+
+ ); +} diff --git a/components/employees/AngehoerigeSection.tsx b/components/employees/AngehoerigeSection.tsx new file mode 100644 index 0000000..140c4a6 --- /dev/null +++ b/components/employees/AngehoerigeSection.tsx @@ -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(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 ( +
+
+

Angehörige

+
+ + {dependents.length} Personen +
+
+ + {dependents.length === 0 ? ( +

Keine Angehörigen hinterlegt.

+ ) : ( +
+ + + + + + + + + + + {dependents.map((d) => ( + + + + + + + + ))} + +
NameVerhältnisSVNRGeburtsdatum +
+ {d.first_name} {d.last_name} + {d.relationship}{d.sv_nummer ?? "–"}{fmtDate(d.birth_date)} + +
+
+ )} + + setModalOpen(false)} employeeId={employeeId} defaultEffectiveDate={resolvedEffectiveDate} /> +
+ ); +} diff --git a/components/employees/EmployeeDetail.tsx b/components/employees/EmployeeDetail.tsx index cccc985..19af0f3 100644 --- a/components/employees/EmployeeDetail.tsx +++ b/components/employees/EmployeeDetail.tsx @@ -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(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 (
@@ -68,7 +74,7 @@ export function EmployeeDetail(props: EmployeeDetailProps) {

- {employee.first_name} {employee.last_name} + {fmtFullName(employee.first_name, employee.last_name, employee.title_prefix, employee.title_suffix)}

@@ -87,15 +93,17 @@ export function EmployeeDetail(props: EmployeeDetailProps) { setPanel("transfer")} /> setPanel("promote")} /> setPanel("karenz")} /> - setPanel("daten")} /> - )} + {canEditData && setPanel("daten")} />} + {isActive && ( + + )} {employee.status === "Ausgetreten" && ( ))}
- {tab === "Stammdaten" && } + {tab === "Stammdaten" && } {tab === "Vertrag" && } - {tab === "Organisation" && } + {tab === "Organisation" && ( + + )} {tab === "Historie" && } + {tab === "HR-Notizen" && }
setPanel(null)} employee={employee} /> setPanel(null)} employee={employee} /> - setPanel(null)} employee={employee} /> + setPanel(null)} employee={employee} dependents={dependents} /> setPanel(null)} employee={employee} directReportCount={directReports.length} /> setPanel(null)} employee={employee} />
diff --git a/components/employees/RoleEmploymentFields.tsx b/components/employees/RoleEmploymentFields.tsx new file mode 100644 index 0000000..2d91580 --- /dev/null +++ b/components/employees/RoleEmploymentFields.tsx @@ -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) => void }) { + function toggleWorkDay(day: Weekday) { + onChange({ workDays: value.workDays.includes(day) ? value.workDays.filter((d) => d !== day) : [...value.workDays, day] }); + } + + return ( +
+
+
+ + +
+
+ + +
+
+ +
+ +
+ {WEEKDAYS.map((day) => ( + + ))} +
+
+ +
+ + + + +
+
+ ); +} diff --git a/components/employees/TitleFields.tsx b/components/employees/TitleFields.tsx new file mode 100644 index 0000000..f192ba3 --- /dev/null +++ b/components/employees/TitleFields.tsx @@ -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) => void }) { + return ( +
+
+ + onChange({ titlePrefix })} /> +
+
+ + onChange({ titleSuffix })} /> +
+
+ ); +} diff --git a/components/employees/panels/DatenAendernPanel.tsx b/components/employees/panels/DatenAendernPanel.tsx index 5f1fcef..6663f68 100644 --- a/components/employees/panels/DatenAendernPanel.tsx +++ b/components/employees/panels/DatenAendernPanel.tsx @@ -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({ titlePrefix: employee.title_prefix ?? [], titleSuffix: employee.title_suffix ?? [] }); + function updateTitles(patch: Partial) { + setTitles((prev) => ({ ...prev, ...patch })); + } const [gender, setGender] = useState(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(employee.contract_type); const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? ""); + const [role, setRole] = useState({ + 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) { + 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={ <>