Phase 1: project foundation for Alpenwerk HR

Scaffolds the Next.js 16 / TypeScript strict / Tailwind v3 app per
NEXTJS_REBUILD_SUPERPROMPT.md, and implements the Foundation slice from
the Phase 1 plan:

- Corrected Supabase schema (supabase/schema.sql): org units, employees,
  history, positions, hire drafts, saved reports, audit log, reorg
  scenarios, role-based profiles, salary-masking view, RLS policies,
  auto-derivation triggers, position-number generator.
- Seed script (supabase/seed.ts): ~800 realistic Austrian employees across
  9 divisions / 16 departments / 35 teams, history, 8 open positions, and
  hr_admin/manager test accounts.
- Supabase clients (lib/supabase/*), design tokens (tailwind.config.ts),
  format/color helpers (lib/format.ts, lib/colors.ts).
- Shared UI kit (components/ui): Avatar, StatusChip, Toast, Modal,
  SlideOver, SegmentedControl, Lookup.
- Auth (login page, Server Actions) and proxy.ts (Next 16's replacement
  for middleware) guarding the authenticated route group.
- Shell (Sidebar, Topbar, NewHireButton stub) and the Dashboard page,
  reading live data via employees_directory.

Employees list/detail, hire wizard, action panels, org chart, positions,
reports, and audit log are deferred to later phases per the plan.
This commit is contained in:
2026-07-13 21:44:28 +02:00
parent d3a5a9fa27
commit ef9852b09c
41 changed files with 9579 additions and 0 deletions

360
supabase/schema.sql Normal file
View File

@@ -0,0 +1,360 @@
-- Alpenwerk HR — initial schema
--
-- Based on spec §3, with the following corrections
-- (see the Phase 1 plan for the full rationale):
-- - gender_type restricted to m/w (spec's domain rules exclude "divers")
-- - employees.location replaced by a locations reference table + location_id FK
-- (the spec's own CHECK listed Wien/Linz/Graz, which contradicts §2's real site list)
-- - nationality constrained to the picklist named in §2
-- - added: locations, profiles (role-based access), employees_directory (salary-masked view)
-- - added: address/address_country/contract_end_date columns (required by §4.5's "Daten ändern"
-- panel and the befristet/"Befristet bis" rule, but missing from §3's literal table)
-- - added: 'Stammdatenänderung' history event type (required by §4.5, missing from §3's enum)
-- - added: reorg_scenarios.undo_snapshot jsonb (required by §4.7's undo feature)
-- - added: position number generation + org-unit auto-derivation as real functions/triggers
create extension if not exists "pgcrypto";
-- ── Org units ────────────────────────────────────────────────
create table divisions ( -- "Bereich", numbers 20xxxxxx
id uuid primary key default gen_random_uuid(),
org_number text not null unique check (org_number ~ '^20\d{6}$'),
name text not null unique
);
create table departments ( -- "Abteilung", numbers 21xxxxxx
id uuid primary key default gen_random_uuid(),
org_number text not null unique check (org_number ~ '^21\d{6}$'),
name text not null,
division_id uuid not null references divisions(id)
);
create table teams ( -- "Team", numbers 22xxxxxx
id uuid primary key default gen_random_uuid(),
org_number text not null unique check (org_number ~ '^22\d{6}$'),
name text not null,
department_id uuid not null references departments(id)
);
-- ── Locations (site ties to a country; country picklist drives the UI's
-- "select country auto-selects its locations" rule from §2) ─────────
create table locations (
id uuid primary key default gen_random_uuid(),
name text not null unique, -- Wien-Hernals, Wolkersdorf, Köln, Brünn, Ljubljana
country text not null check (country in ('Österreich', 'Deutschland', 'Tschechien', 'Slowenien'))
);
-- ── Profiles (role-based access per §6) ─────────────────────
create table profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text not null,
full_name text,
role text not null default 'manager' check (role in ('hr_admin', 'manager')),
created_at timestamptz not null default now()
);
-- ── Employees ────────────────────────────────────────────────
create type employment_status as enum ('Aktiv', 'Karenz', 'Geplant', 'Ausgetreten');
create type employment_type as enum ('Vollzeit', 'Teilzeit');
create type contract_type as enum ('unbefristet', 'befristet');
create type paygrade_type as enum ('A', 'B', 'C', 'D', 'E', 'F');
create type source_type as enum ('Intern', 'Extern');
create type gender_type as enum ('m', 'w');
create table employees (
id uuid primary key default gen_random_uuid(),
personnel_number int generated always as identity (start with 1001), -- Pers.-Nr.
first_name text not null,
last_name text not null,
gender gender_type not null,
birth_date date not null,
sv_nummer text,
nationality text not null default 'Österreich' check (nationality in (
'Österreich', 'Deutschland', 'Tschechien', 'Slowenien', 'Türkei',
'Serbien', 'Kroatien', 'Bosnien', 'Ungarn', 'Andere'
)),
address text,
address_country text check (address_country in ('Österreich', 'Deutschland', 'Tschechien', 'Slowenien', 'Andere')),
email text not null unique,
phone text,
team_id uuid references teams(id), -- nullable only for CEO / division heads without a team
division_id uuid not null references divisions(id), -- auto-derived from team_id by trigger when team_id is set
job_title text not null,
location_id uuid not null references locations(id),
manager_id uuid references employees(id),
org_level int not null default 3 check (org_level between 0 and 3), -- 0=CEO,1=division head,2=team lead,3=IC
is_lead boolean not null default false,
employment_type employment_type not null default 'Vollzeit',
weekly_hours numeric(4,1) not null default 38.5,
monthly_salary_gross numeric(10,2) not null check (monthly_salary_gross > 0), -- 14x/year convention
contract_type contract_type not null default 'unbefristet',
contract_end_date date,
paygrade paygrade_type not null default 'B',
source source_type not null default 'Extern',
status employment_status not null default 'Aktiv',
entry_date date not null,
exit_date date,
exit_reason text,
karenz_return_date date,
avatar_color text, -- hex, for initials badge; app falls back to a deterministic hash if null
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint chk_exit_after_entry check (exit_date is null or exit_date >= entry_date),
constraint chk_karenz_return_after_entry check (karenz_return_date is null or karenz_return_date >= entry_date),
constraint chk_befristet_end check (contract_type <> 'befristet' or contract_end_date is not null),
constraint chk_weekly_hours check (
(employment_type = 'Vollzeit' and weekly_hours = 38.5) or
(employment_type = 'Teilzeit' and weekly_hours > 0 and weekly_hours < 38.5)
)
);
create index on employees (team_id);
create index on employees (division_id);
create index on employees (manager_id);
create index on employees (status);
-- ── Employee history (append-only audit trail per person) ───
create type history_event_type as enum (
'Eintritt', 'Beförderung', 'Versetzung', 'Karenz', 'Vertragsänderung', 'Stammdatenänderung',
'Austritt', 'Wiedereintritt', 'Reorganisation', 'Gehaltsanpassung', 'Rückkehr'
);
create table employee_history (
id uuid primary key default gen_random_uuid(),
employee_id uuid not null references employees(id) on delete cascade,
event_date date not null,
event_type history_event_type not null,
description text not null,
created_at timestamptz not null default now()
);
create index on employee_history (employee_id, event_date desc);
-- ── Positions (Planstellen) ──────────────────────────────────
create table positions (
id uuid primary key default gen_random_uuid(),
position_number text not null unique check (position_number ~ '^6\d{7}$'),
title text not null,
team_id uuid not null references teams(id),
division_id uuid not null references divisions(id), -- auto-derived from team_id by trigger
is_lead boolean not null default false,
reports_to_employee_id uuid references employees(id), -- the superior manager chosen at creation
status text not null default 'open' check (status in ('open', 'filled')),
created_at timestamptz not null default now(),
filled_at timestamptz,
filled_by_employee_id uuid references employees(id)
);
create index on positions (team_id);
create index on positions (status);
-- ── Hire drafts (resumable wizard state) ─────────────────────
create table hire_drafts (
id uuid primary key default gen_random_uuid(),
created_by uuid references auth.users(id),
step int not null default 0,
payload jsonb not null, -- full wizard form state
updated_at timestamptz not null default now()
);
-- ── Saved reports ────────────────────────────────────────────
create table saved_reports (
id uuid primary key default gen_random_uuid(),
created_by uuid references auth.users(id),
name text not null,
config jsonb not null, -- { measure, group, split, filters... }
created_at timestamptz not null default now()
);
-- ── Audit log (system-wide, immutable) ───────────────────────
create table audit_log (
id uuid primary key default gen_random_uuid(),
occurred_at timestamptz not null default now(),
actor_user_id uuid references auth.users(id),
actor_name text not null,
action text not null, -- e.g. 'Neueinstellung','Austritt','Versetzung','Beförderung','Karenz',
-- 'Vertragsänderung','Stammdatenänderung','Wiedereinstellung','Ausschreibung',
-- 'Interne Besetzung','Reorganisation','Reorganisation rückgängig','Rückkehr',
-- 'Gehaltsanpassung'
target_label text not null, -- human-readable name of what changed
target_employee_id uuid references employees(id),
details text
);
create index on audit_log (occurred_at desc);
-- ── Reorg scenarios (persistence of in-progress/applied reorg plans) ─
create table reorg_scenarios (
id uuid primary key default gen_random_uuid(),
name text not null,
effective_date date not null,
created_by uuid references auth.users(id),
applied boolean not null default false,
applied_at timestamptz,
undo_snapshot jsonb, -- pre-change employee state + history/audit high-water marks, for undo
created_at timestamptz not null default now()
);
create table reorg_moves (
id uuid primary key default gen_random_uuid(),
scenario_id uuid not null references reorg_scenarios(id) on delete cascade,
kind text not null check (kind in ('emp', 'team', 'abt', 'dept')),
payload jsonb not null -- employee ids / team id / dept id / target division, counts, labels
);
-- ── Functions & triggers ─────────────────────────────────────
-- Auto-derive division_id from team_id (keeps the denormalized division in sync
-- with the team's real parent chain; §3's closing instruction).
create or replace function fn_set_employee_org_unit()
returns trigger
language plpgsql
as $$
begin
if new.team_id is not null then
select dep.division_id into new.division_id
from teams t
join departments dep on dep.id = t.department_id
where t.id = new.team_id;
end if;
return new;
end;
$$;
create trigger trg_employees_set_org_unit
before insert or update of team_id on employees
for each row execute function fn_set_employee_org_unit();
create or replace function fn_set_position_org_unit()
returns trigger
language plpgsql
as $$
begin
select dep.division_id into new.division_id
from teams t
join departments dep on dep.id = t.department_id
where t.id = new.team_id;
return new;
end;
$$;
create trigger trg_positions_set_org_unit
before insert or update of team_id on positions
for each row execute function fn_set_position_org_unit();
create or replace function fn_touch_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger trg_employees_touch_updated_at
before update on employees
for each row execute function fn_touch_updated_at();
-- Unique 8-digit position numbers starting with '6' (§2).
create or replace function generate_position_number()
returns text
language plpgsql
as $$
declare
candidate text;
begin
loop
candidate := '6' || lpad(floor(random() * 10000000)::text, 7, '0');
exit when not exists (select 1 from positions where position_number = candidate);
end loop;
return candidate;
end;
$$;
alter table positions alter column position_number set default generate_position_number();
-- ── Role helper (SECURITY DEFINER avoids RLS recursion on profiles) ──
create or replace function is_hr_admin()
returns boolean
language sql
security definer
set search_path = public
stable
as $$
select exists (
select 1 from profiles p where p.id = auth.uid() and p.role = 'hr_admin'
);
$$;
-- ── Salary-masked read view for the manager role (§6) ────────
-- Owned by the migration role (postgres), which bypasses RLS on the base
-- table, so this view is reachable by both roles while column-masking
-- salary per session via is_hr_admin().
create view employees_directory as
select
e.id, e.personnel_number, e.first_name, e.last_name, e.gender, e.birth_date, e.sv_nummer,
e.nationality, e.address, e.address_country, e.email, e.phone, e.team_id, e.division_id,
e.job_title, e.location_id, e.manager_id, e.org_level, e.is_lead, e.employment_type,
e.weekly_hours,
case when is_hr_admin() then e.monthly_salary_gross else null end as monthly_salary_gross,
e.contract_type, e.contract_end_date, e.paygrade, e.source, e.status, e.entry_date, e.exit_date,
e.exit_reason, e.karenz_return_date, e.avatar_color, e.created_at, e.updated_at
from employees e;
grant select on employees_directory to authenticated;
-- ── Row Level Security ───────────────────────────────────────
alter table divisions enable row level security;
alter table departments enable row level security;
alter table teams enable row level security;
alter table locations enable row level security;
alter table profiles enable row level security;
alter table employees enable row level security;
alter table employee_history enable row level security;
alter table positions enable row level security;
alter table hire_drafts enable row level security;
alter table saved_reports enable row level security;
alter table audit_log enable row level security;
alter table reorg_scenarios enable row level security;
alter table reorg_moves enable row level security;
-- Org reference data: readable by any authenticated user, writable by hr_admin only.
create policy "org_read" on divisions for select using (auth.role() = 'authenticated');
create policy "org_write" on divisions for all using (is_hr_admin()) with check (is_hr_admin());
create policy "org_read" on departments for select using (auth.role() = 'authenticated');
create policy "org_write" on departments for all using (is_hr_admin()) with check (is_hr_admin());
create policy "org_read" on teams for select using (auth.role() = 'authenticated');
create policy "org_write" on teams for all using (is_hr_admin()) with check (is_hr_admin());
create policy "org_read" on locations for select using (auth.role() = 'authenticated');
create policy "org_write" on locations for all using (is_hr_admin()) with check (is_hr_admin());
-- Profiles: users read their own row; hr_admin reads/writes all.
create policy "profiles_select_own" on profiles for select using (auth.uid() = id);
create policy "profiles_select_admin" on profiles for select using (is_hr_admin());
create policy "profiles_write_admin" on profiles for insert with check (is_hr_admin());
create policy "profiles_update_admin" on profiles for update using (is_hr_admin()) with check (is_hr_admin());
-- Employees: only hr_admin reads/writes the base table directly. The manager
-- role reads through employees_directory instead (salary masked there).
create policy "employees_admin_all" on employees for all using (is_hr_admin()) with check (is_hr_admin());
-- Employee history: any authenticated user can read; only hr_admin can append; immutable otherwise.
create policy "history_read" on employee_history for select using (auth.role() = 'authenticated');
create policy "history_insert_admin" on employee_history for insert with check (is_hr_admin());
-- Positions: any authenticated user can browse open positions; hr_admin manages them.
create policy "positions_read" on positions for select using (auth.role() = 'authenticated');
create policy "positions_write_admin" on positions for all using (is_hr_admin()) with check (is_hr_admin());
-- Hire drafts: scoped to their creator.
create policy "hire_drafts_owner" on hire_drafts for all
using (created_by = auth.uid()) with check (created_by = auth.uid());
-- Saved reports: scoped to their creator.
create policy "saved_reports_owner" on saved_reports for all
using (created_by = auth.uid()) with check (created_by = auth.uid());
-- Audit log: any authenticated user can read; only hr_admin can append; immutable (no update/delete policy).
create policy "audit_read" on audit_log for select using (auth.role() = 'authenticated');
create policy "audit_insert_admin" on audit_log for insert with check (is_hr_admin());
-- Reorg scenarios/moves: any authenticated user can see the (small) recent list; hr_admin manages them.
create policy "reorg_scenarios_read" on reorg_scenarios for select using (auth.role() = 'authenticated');
create policy "reorg_scenarios_write_admin" on reorg_scenarios for all using (is_hr_admin()) with check (is_hr_admin());
create policy "reorg_moves_read" on reorg_moves for select using (auth.role() = 'authenticated');
create policy "reorg_moves_write_admin" on reorg_moves for all using (is_hr_admin()) with check (is_hr_admin());