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.
361 lines
17 KiB
PL/PgSQL
361 lines
17 KiB
PL/PgSQL
-- 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());
|