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());

708
supabase/seed.ts Normal file
View File

@@ -0,0 +1,708 @@
// Seeds ~800 realistic Austrian/DACH employees + org structure + a handful of
// open positions, per spec §5.
//
// Run with: node --env-file=.env.local supabase/seed.ts
// Uses the service-role key over the Supabase REST API (bypasses RLS) — no
// direct Postgres connection needed. All row ids are generated client-side
// so parent/child references never require a round-trip.
import { createClient } from "@supabase/supabase-js";
import { randomUUID } from "node:crypto";
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY in the environment");
}
const ADMIN_EMAIL = "m.stubhan@loudspring.at";
const MANAGER_TEST_EMAIL = "manager-test@test.manner.at";
const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
});
// ── RNG helpers ──────────────────────────────────────────────
function randInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function pick<T>(arr: readonly T[]): T {
return arr[randInt(0, arr.length - 1)];
}
function chance(probability: number): boolean {
return Math.random() < probability;
}
function weightedPick<T>(entries: readonly (readonly [T, number])[]): T {
const total = entries.reduce((sum, [, w]) => sum + w, 0);
let r = Math.random() * total;
for (const [value, w] of entries) {
r -= w;
if (r <= 0) return value;
}
return entries[entries.length - 1][0];
}
function addDays(d: Date, days: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + days);
return r;
}
function isoDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
function randomDateBetween(start: Date, end: Date): Date {
const t = start.getTime() + Math.random() * (end.getTime() - start.getTime());
return new Date(t);
}
function slugify(s: string): string {
return s
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "");
}
const TODAY = new Date();
// ── Name pools ───────────────────────────────────────────────
const MALE_FIRST_NAMES = [
"Michael", "Andreas", "Thomas", "Stefan", "Christian", "Martin", "Markus", "Daniel",
"Christoph", "Alexander", "Wolfgang", "Peter", "Josef", "Franz", "Johann", "Georg",
"Bernhard", "Florian", "Manuel", "Philipp", "Sebastian", "Patrick", "Dominik", "Simon",
"Lukas", "David", "Matthias", "Robert", "Gerhard", "Helmut", "Karl", "Anton", "Rudolf",
"Herbert", "Kurt", "Werner", "Erwin", "Hannes", "Fabian", "Julian",
];
const FEMALE_FIRST_NAMES = [
"Maria", "Anna", "Sabine", "Andrea", "Claudia", "Petra", "Julia", "Sarah", "Lisa",
"Nicole", "Christine", "Elisabeth", "Monika", "Barbara", "Karin", "Silvia", "Martina",
"Susanne", "Katharina", "Eva", "Michaela", "Stephanie", "Verena", "Melanie", "Sandra",
"Birgit", "Ingrid", "Renate", "Gabriele", "Brigitte", "Theresa", "Laura", "Hannah",
"Johanna", "Magdalena", "Carina", "Vanessa", "Nadine", "Bettina", "Ursula",
];
const LAST_NAMES = [
"Gruber", "Huber", "Bauer", "Wagner", "Müller", "Pichler", "Steiner", "Moser", "Mayer",
"Hofer", "Leitner", "Berger", "Fuchs", "Eder", "Fischer", "Schmid", "Winkler", "Weber",
"Schwarz", "Maier", "Schneider", "Reiter", "Mayr", "Wolf", "Aigner", "Lang",
"Baumgartner", "Auer", "Brunner", "Wallner", "Wimmer", "Egger", "Binder", "Wieser",
"Höller", "Schmidt", "Riegler", "Kaiser", "Lechner", "Kogler", "Peer", "Lehner",
"Zimmermann", "Pöll", "Haas", "Novak", "Horvat", "Vukovic", "Yilmaz", "Demir", "Kovac",
"Nemec", "Simic", "Kralj", "Toth", "Szabo",
];
const NATIONALITIES: readonly (readonly [string, number])[] = [
["Österreich", 75],
["Deutschland", 5],
["Tschechien", 3],
["Slowenien", 2],
["Türkei", 5],
["Serbien", 3],
["Kroatien", 3],
["Bosnien", 2],
["Ungarn", 2],
];
function addressCountryFor(nationality: string): string {
return ["Österreich", "Deutschland", "Tschechien", "Slowenien"].includes(nationality) ? nationality : "Andere";
}
const STREETS = ["Hauptstraße", "Bahnhofstraße", "Schulgasse", "Kirchenplatz", "Gartenweg", "Industriestraße", "Ringstraße", "Feldweg"];
const EXIT_REASONS = [
"Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung",
];
// ── Locations ────────────────────────────────────────────────
const LOCATIONS = [
{ id: randomUUID(), name: "Wien-Hernals", country: "Österreich" },
{ id: randomUUID(), name: "Wolkersdorf", country: "Österreich" },
{ id: randomUUID(), name: "Köln", country: "Deutschland" },
{ id: randomUUID(), name: "Brünn", country: "Tschechien" },
{ id: randomUUID(), name: "Ljubljana", country: "Slowenien" },
] as const;
const LOCATION_WEIGHTS: readonly (readonly [(typeof LOCATIONS)[number], number])[] = [
[LOCATIONS[0], 55],
[LOCATIONS[1], 20],
[LOCATIONS[2], 10],
[LOCATIONS[3], 10],
[LOCATIONS[4], 5],
];
// ── Org structure ────────────────────────────────────────────
type TeamDef = { name: string; leadTitle: string; icTitles: string[]; baseSize: number };
type DeptDef = { name: string; teams: TeamDef[] };
type DivisionDef = { name: string; headTitle: string; departments: DeptDef[] };
const SCALE = 1.44; // brings the ~556-person base roster up to ~800
const DIVISIONS: DivisionDef[] = [
{
name: "Produktion",
headTitle: "Bereichsleitung Produktion",
departments: [
{
name: "Fertigung",
teams: [
{ name: "Montage", leadTitle: "Teamleitung Montage", icTitles: ["Maschinenbediener:in", "Montagemitarbeiter:in", "Anlagenführer:in"], baseSize: 45 },
{ name: "CNC-Fertigung", leadTitle: "Teamleitung CNC-Fertigung", icTitles: ["CNC-Fräser:in", "CNC-Dreher:in", "Zerspanungstechniker:in"], baseSize: 35 },
{ name: "Qualitätssicherung Fertigung", leadTitle: "Teamleitung Qualitätssicherung Fertigung", icTitles: ["Qualitätsprüfer:in", "Messtechniker:in"], baseSize: 22 },
],
},
{
name: "Instandhaltung",
teams: [
{ name: "Elektrotechnik", leadTitle: "Teamleitung Elektrotechnik", icTitles: ["Elektrotechniker:in", "Automatisierungstechniker:in"], baseSize: 24 },
{ name: "Mechanik", leadTitle: "Teamleitung Mechanik", icTitles: ["Industriemechaniker:in", "Schlosser:in"], baseSize: 22 },
],
},
],
},
{
name: "Logistik & Einkauf",
headTitle: "Bereichsleitung Logistik & Einkauf",
departments: [
{
name: "Logistik",
teams: [
{ name: "Lager", leadTitle: "Teamleitung Lager", icTitles: ["Lagerlogistiker:in", "Staplerfahrer:in", "Kommissionierer:in"], baseSize: 30 },
{ name: "Versand", leadTitle: "Teamleitung Versand", icTitles: ["Versandmitarbeiter:in", "Speditionskaufmann/-frau"], baseSize: 20 },
{ name: "Fuhrpark", leadTitle: "Teamleitung Fuhrpark", icTitles: ["Berufskraftfahrer:in", "Fuhrparkdisponent:in"], baseSize: 16 },
],
},
{
name: "Einkauf",
teams: [
{ name: "Strategischer Einkauf", leadTitle: "Teamleitung Strategischer Einkauf", icTitles: ["Einkäufer:in", "Category Manager:in"], baseSize: 14 },
{ name: "Operativer Einkauf", leadTitle: "Teamleitung Operativer Einkauf", icTitles: ["Operative:r Einkäufer:in", "Bestelldisponent:in"], baseSize: 14 },
],
},
],
},
{
name: "Vertrieb & Marketing",
headTitle: "Bereichsleitung Vertrieb & Marketing",
departments: [
{
name: "Vertrieb",
teams: [
{ name: "Key Account Management", leadTitle: "Teamleitung Key Account Management", icTitles: ["Key Account Manager:in", "Sales Manager:in"], baseSize: 16 },
{ name: "Außendienst", leadTitle: "Teamleitung Außendienst", icTitles: ["Außendienstmitarbeiter:in", "Gebietsverkaufsleiter:in"], baseSize: 22 },
{ name: "Vertriebsinnendienst", leadTitle: "Teamleitung Vertriebsinnendienst", icTitles: ["Vertriebsinnendienstmitarbeiter:in", "Auftragssachbearbeiter:in"], baseSize: 18 },
],
},
{
name: "Marketing",
teams: [
{ name: "Brand Marketing", leadTitle: "Teamleitung Brand Marketing", icTitles: ["Brand Manager:in", "Produktmanager:in"], baseSize: 12 },
{ name: "Digital Marketing", leadTitle: "Teamleitung Digital Marketing", icTitles: ["Digital Marketing Manager:in", "Social-Media-Manager:in"], baseSize: 12 },
],
},
],
},
{
name: "Forschung & Entwicklung",
headTitle: "Bereichsleitung Forschung & Entwicklung",
departments: [
{
name: "Produktentwicklung",
teams: [
{ name: "Rezeptur & Sensorik", leadTitle: "Teamleitung Rezeptur & Sensorik", icTitles: ["Lebensmitteltechniker:in", "Sensoriker:in"], baseSize: 16 },
{ name: "Verpackungsentwicklung", leadTitle: "Teamleitung Verpackungsentwicklung", icTitles: ["Verpackungstechniker:in", "Packmittelentwickler:in"], baseSize: 12 },
],
},
{
name: "Verfahrenstechnik",
teams: [
{ name: "Prozessoptimierung", leadTitle: "Teamleitung Prozessoptimierung", icTitles: ["Verfahrenstechniker:in", "Prozessingenieur:in"], baseSize: 14 },
{ name: "Anlagentechnik", leadTitle: "Teamleitung Anlagentechnik", icTitles: ["Anlagentechniker:in", "Projektingenieur:in"], baseSize: 12 },
],
},
],
},
{
name: "Qualitätsmanagement",
headTitle: "Bereichsleitung Qualitätsmanagement",
departments: [
{
name: "Qualitätssicherung",
teams: [
{ name: "Wareneingangsprüfung", leadTitle: "Teamleitung Wareneingangsprüfung", icTitles: ["Qualitätsprüfer:in", "Wareneingangskontrolleur:in"], baseSize: 14 },
{ name: "Prozessaudit", leadTitle: "Teamleitung Prozessaudit", icTitles: ["Qualitätsauditor:in", "QM-Beauftragte:r"], baseSize: 10 },
],
},
{
name: "Lebensmittelsicherheit",
teams: [
{ name: "Hygienemanagement", leadTitle: "Teamleitung Hygienemanagement", icTitles: ["Hygienebeauftragte:r", "Lebensmittelsicherheitsbeauftragte:r"], baseSize: 12 },
{ name: "Zertifizierung", leadTitle: "Teamleitung Zertifizierung", icTitles: ["Zertifizierungsmanager:in", "QM-Sachbearbeiter:in"], baseSize: 10 },
],
},
],
},
{
name: "IT",
headTitle: "Bereichsleitung IT",
departments: [
{
name: "Business Applications",
teams: [
{ name: "SAP-Team", leadTitle: "Teamleitung SAP-Team", icTitles: ["SAP-Consultant", "SAP-Entwickler:in"], baseSize: 12 },
{ name: "Power Platform & Automatisierung", leadTitle: "Teamleitung Power Platform & Automatisierung", icTitles: ["Power Platform Developer:in", "Prozessautomatisierer:in"], baseSize: 10 },
],
},
{
name: "Infrastruktur",
teams: [
{ name: "Netzwerk & Security", leadTitle: "Teamleitung Netzwerk & Security", icTitles: ["Netzwerktechniker:in", "IT-Security-Spezialist:in"], baseSize: 12 },
{ name: "IT-Support", leadTitle: "Teamleitung IT-Support", icTitles: ["IT-Support-Mitarbeiter:in", "Systemadministrator:in"], baseSize: 14 },
],
},
],
},
{
name: "Finanzen & Controlling",
headTitle: "Bereichsleitung Finanzen & Controlling",
departments: [
{
name: "Finanzen",
teams: [
{ name: "Buchhaltung", leadTitle: "Teamleitung Buchhaltung", icTitles: ["Buchhalter:in", "Bilanzbuchhalter:in"], baseSize: 16 },
{ name: "Treasury", leadTitle: "Teamleitung Treasury", icTitles: ["Treasury-Manager:in", "Finanzanalyst:in"], baseSize: 10 },
],
},
{
name: "Controlling",
teams: [
{ name: "Konzerncontrolling", leadTitle: "Teamleitung Konzerncontrolling", icTitles: ["Controller:in", "Financial Analyst:in"], baseSize: 12 },
{ name: "Werkscontrolling", leadTitle: "Teamleitung Werkscontrolling", icTitles: ["Werkscontroller:in", "Kostenrechner:in"], baseSize: 12 },
],
},
],
},
{
name: "Human Resources",
headTitle: "Bereichsleitung Human Resources",
departments: [
{
name: "HR Business Partner",
teams: [
{ name: "Recruiting", leadTitle: "Teamleitung Recruiting", icTitles: ["Recruiter:in", "Talent Acquisition Manager:in"], baseSize: 10 },
{ name: "Personalentwicklung", leadTitle: "Teamleitung Personalentwicklung", icTitles: ["Personalentwickler:in", "Trainer:in"], baseSize: 8 },
],
},
{
name: "Personaladministration",
teams: [
{ name: "Gehaltsabrechnung", leadTitle: "Teamleitung Gehaltsabrechnung", icTitles: ["Payroll-Spezialist:in", "Personalverrechner:in"], baseSize: 10 },
{ name: "HR-Systeme", leadTitle: "Teamleitung HR-Systeme", icTitles: ["HR-IT-Spezialist:in", "HRIS Manager:in"], baseSize: 8 },
],
},
],
},
];
// ── Employee generation ──────────────────────────────────────
type EmployeeRow = {
id: string;
first_name: string;
last_name: string;
gender: "m" | "w";
birth_date: string;
sv_nummer: string;
nationality: string;
address: string;
address_country: string;
email: string;
phone: string;
team_id: string | null;
division_id: string;
job_title: string;
location_id: string;
manager_id: string | null;
org_level: number;
is_lead: boolean;
employment_type: "Vollzeit" | "Teilzeit";
weekly_hours: number;
monthly_salary_gross: number;
contract_type: "unbefristet" | "befristet";
contract_end_date: string | null;
paygrade: "A" | "B" | "C" | "D" | "E" | "F";
source: "Intern" | "Extern";
status: "Aktiv" | "Karenz" | "Geplant" | "Ausgetreten";
entry_date: string;
exit_date: string | null;
exit_reason: string | null;
karenz_return_date: string | null;
};
type HistoryRow = {
employee_id: string;
event_date: string;
event_type: string;
description: string;
};
const usedEmails = new Set<string>();
function makeEmail(firstName: string, lastName: string): string {
const base = `${slugify(firstName)}.${slugify(lastName)}`;
let email = `${base}@test.manner.at`;
let n = 2;
while (usedEmails.has(email)) {
email = `${base}${n}@test.manner.at`;
n += 1;
}
usedEmails.add(email);
return email;
}
function makeSvNummer(birthDate: Date): string {
const dd = String(birthDate.getDate()).padStart(2, "0");
const mm = String(birthDate.getMonth() + 1).padStart(2, "0");
const yy = String(birthDate.getFullYear()).slice(-2);
const prefix = String(randInt(1000, 9999));
return `${prefix} ${dd}${mm}${yy}`;
}
function birthDateForAge(age: number): Date {
const year = TODAY.getFullYear() - age;
return new Date(year, randInt(0, 11), randInt(1, 28));
}
function paygradeAndSalaryForIc(): { paygrade: EmployeeRow["paygrade"]; salary: number } {
const grade = weightedPick<EmployeeRow["paygrade"]>([
["A", 15],
["B", 35],
["C", 30],
["D", 20],
]);
const ranges: Record<string, [number, number]> = {
A: [2200, 2700],
B: [2600, 3300],
C: [3200, 4100],
D: [4000, 5200],
};
const [min, max] = ranges[grade];
return { paygrade: grade, salary: randInt(min, max) };
}
function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId: string | null, divisionId: string, managerId: string | null) {
const gender: "m" | "w" = chance(0.48) ? "m" : "w";
const firstName = pick(gender === "m" ? MALE_FIRST_NAMES : FEMALE_FIRST_NAMES);
const lastName = pick(LAST_NAMES);
const nationality = weightedPick(NATIONALITIES);
const location = weightedPick(LOCATION_WEIGHTS);
return {
id: randomUUID(),
first_name: firstName,
last_name: lastName,
gender,
nationality,
address: `${pick(STREETS)} ${randInt(1, 90)}, ${randInt(1010, 2500)} Wien`,
address_country: addressCountryFor(nationality),
email: makeEmail(firstName, lastName),
phone: `+43 664 ${randInt(1000000, 9999999)}`,
team_id: teamId,
division_id: divisionId,
job_title: jobTitle,
location_id: location.id,
manager_id: managerId,
org_level: orgLevel,
is_lead: isLead,
};
}
const employees: EmployeeRow[] = [];
const history: HistoryRow[] = [];
const icPoolForStatusAssignment: EmployeeRow[] = [];
function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { salary: number; paygrade: EmployeeRow["paygrade"] }): EmployeeRow {
const age = randInt(22, 60);
const birthDate = birthDateForAge(age);
const maxTenureYears = Math.min(15, age - 20);
const entryDate = randomDateBetween(addDays(TODAY, -maxTenureYears * 365), addDays(TODAY, -30));
const employmentType: "Vollzeit" | "Teilzeit" = chance(0.8) ? "Vollzeit" : "Teilzeit";
const weeklyHours = employmentType === "Vollzeit" ? 38.5 : pick([15, 18, 20, 25, 28, 30, 32, 35]);
const isBefristet = chance(0.1) && entryDate > addDays(TODAY, -540);
const contractEndDate = isBefristet ? addDays(TODAY, randInt(90, 540)) : null;
const row: EmployeeRow = {
...base,
birth_date: isoDate(birthDate),
sv_nummer: makeSvNummer(birthDate),
employment_type: employmentType,
weekly_hours: weeklyHours,
monthly_salary_gross: opts.salary,
contract_type: isBefristet ? "befristet" : "unbefristet",
contract_end_date: contractEndDate ? isoDate(contractEndDate) : null,
paygrade: opts.paygrade,
source: chance(0.15) ? "Intern" : "Extern",
status: "Aktiv",
entry_date: isoDate(entryDate),
exit_date: null,
exit_reason: null,
karenz_return_date: null,
};
history.push({
employee_id: row.id,
event_date: row.entry_date,
event_type: "Eintritt",
description: `Eintritt als ${row.job_title}`,
});
if (row.status === "Aktiv" && entryDate < addDays(TODAY, -2 * 365) && chance(0.06)) {
const promoDate = randomDateBetween(addDays(entryDate, 365), addDays(TODAY, -30));
history.push({
employee_id: row.id,
event_date: isoDate(promoDate),
event_type: "Beförderung",
description: `Beförderung im Rahmen der Laufbahnentwicklung, neue Position: ${row.job_title}`,
});
}
if (chance(0.08)) {
const adjDate = randomDateBetween(addDays(entryDate, 180), TODAY);
history.push({
employee_id: row.id,
event_date: isoDate(adjDate),
event_type: "Gehaltsanpassung",
description: "Jährliche Gehaltsanpassung im Rahmen der Kollektivvertragsrunde",
});
}
return row;
}
type TeamRef = { id: string; org_number: string; name: string; department_id: string };
type DeptRef = { id: string; org_number: string; name: string; division_id: string };
type DivisionRef = { id: string; org_number: string; name: string };
const divisionRows: DivisionRef[] = [];
const departmentRows: DeptRef[] = [];
const teamRows: TeamRef[] = [];
// Geschäftsführung: small division, no departments/teams — CEO and their
// assistant sit directly under it (§5).
const gfDivisionId = randomUUID();
divisionRows.push({ id: gfDivisionId, org_number: "20900000", name: "Geschäftsführung" });
const ceo = finalizeEmployee(
newHireBase("Geschäftsführer:in", 0, true, null, gfDivisionId, null),
{ salary: 15500, paygrade: "F" }
);
const gfAssistant = finalizeEmployee(
newHireBase("Assistenz der Geschäftsführung", 3, false, null, gfDivisionId, ceo.id),
{ salary: 3900, paygrade: "C" }
);
employees.push(ceo, gfAssistant);
let divisionCounter = 0;
let deptCounter = 0;
let teamCounter = 0;
for (const div of DIVISIONS) {
divisionCounter += 1;
const divisionId = randomUUID();
const divisionOrgNumber = `20${String(divisionCounter * 100000).padStart(6, "0")}`;
divisionRows.push({ id: divisionId, org_number: divisionOrgNumber, name: div.name });
const divisionHead = finalizeEmployee(
newHireBase(div.headTitle, 1, true, null, divisionId, ceo.id),
{ salary: randInt(9000, 11500), paygrade: "F" }
);
employees.push(divisionHead);
for (const dept of div.departments) {
deptCounter += 1;
const departmentId = randomUUID();
const deptOrgNumber = `21${String(deptCounter * 10000).padStart(6, "0")}`;
departmentRows.push({ id: departmentId, org_number: deptOrgNumber, name: dept.name, division_id: divisionId });
for (const team of dept.teams) {
teamCounter += 1;
const teamId = randomUUID();
const teamOrgNumber = `22${String(teamCounter * 1000).padStart(6, "0")}`;
teamRows.push({ id: teamId, org_number: teamOrgNumber, name: team.name, department_id: departmentId });
const size = Math.max(2, Math.round(team.baseSize * SCALE));
const teamLead = finalizeEmployee(
newHireBase(team.leadTitle, 2, true, teamId, divisionId, divisionHead.id),
{ salary: randInt(5000, 6800), paygrade: "E" }
);
employees.push(teamLead);
for (let i = 0; i < size - 1; i++) {
const jobTitle = pick(team.icTitles);
const { paygrade, salary } = paygradeAndSalaryForIc();
const ic = finalizeEmployee(
newHireBase(jobTitle, 3, false, teamId, divisionId, teamLead.id),
{ salary, paygrade }
);
employees.push(ic);
icPoolForStatusAssignment.push(ic);
}
}
}
}
// ── Apply the target status distribution (§5) across the IC pool ────────
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = randInt(0, i);
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
const shuffledIcs = shuffle(icPoolForStatusAssignment);
let cursor = 0;
// ~40 Ausgetreten
for (let i = 0; i < 40 && cursor < shuffledIcs.length; i++, cursor++) {
const e = shuffledIcs[cursor];
const entryDate = new Date(e.entry_date);
const exitDate = randomDateBetween(addDays(entryDate, 90), TODAY);
e.status = "Ausgetreten";
e.exit_date = isoDate(exitDate);
e.exit_reason = pick(EXIT_REASONS);
history.push({ employee_id: e.id, event_date: e.exit_date, event_type: "Austritt", description: `Austritt (${e.exit_reason})` });
}
// ~12 Karenz
for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) {
const e = shuffledIcs[cursor];
const entryDate = new Date(e.entry_date);
const karenzStart = randomDateBetween(addDays(entryDate, 180), addDays(TODAY, -10));
const returnDate = addDays(TODAY, randInt(10, 300));
e.status = "Karenz";
e.karenz_return_date = isoDate(returnDate);
history.push({
employee_id: e.id,
event_date: isoDate(karenzStart),
event_type: "Karenz",
description: `Karenzantritt, geplante Rückkehr am ${isoDate(returnDate)}`,
});
}
// ~3 Geplant (future entry — overwrite entry_date/history)
for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) {
const e = shuffledIcs[cursor];
const futureEntry = addDays(TODAY, randInt(10, 90));
e.status = "Geplant";
e.entry_date = isoDate(futureEntry);
const historyEntry = history.find((h) => h.employee_id === e.id && h.event_type === "Eintritt");
if (historyEntry) historyEntry.event_date = e.entry_date;
}
// ~3 planned future exits (still Aktiv until the exit date arrives)
for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) {
const e = shuffledIcs[cursor];
const futureExit = addDays(TODAY, randInt(10, 90));
e.exit_date = isoDate(futureExit);
e.exit_reason = pick(EXIT_REASONS);
}
// ── Open positions (§4.6 / §5) ───────────────────────────────
type PositionRow = {
title: string;
team_id: string;
is_lead: boolean;
reports_to_employee_id: string | null;
status: "open";
created_at: string;
};
const positions: PositionRow[] = [];
{
const pool = shuffle([...teamRows]);
for (let i = 0; i < 8; i++) {
const team = pool[i % pool.length];
const leadOfTeam = employees.find((e) => e.team_id === team.id && e.is_lead);
const isLeadPosition = i < 2; // first two are leadership requisitions
const reportsTo = isLeadPosition
? (employees.find((e) => e.division_id === leadOfTeam?.division_id && e.org_level === 1)?.id ?? null)
: (leadOfTeam?.id ?? null);
positions.push({
title: isLeadPosition ? `Teamleitung ${team.name}` : "Neue Position",
team_id: team.id,
is_lead: isLeadPosition,
reports_to_employee_id: reportsTo,
status: "open",
created_at: new Date(addDays(TODAY, -randInt(1, 45))).toISOString(),
});
}
}
// ── Insert helpers ───────────────────────────────────────────
async function insertInChunks(table: string, rows: Record<string, unknown>[], chunkSize = 200) {
for (let i = 0; i < rows.length; i += chunkSize) {
const chunk = rows.slice(i, i + chunkSize);
const { error } = await supabase.from(table).insert(chunk);
if (error) throw new Error(`Insert into ${table} failed: ${error.message}`);
}
console.log(` inserted ${rows.length} row(s) into ${table}`);
}
async function main() {
console.log("Seeding locations...");
await insertInChunks("locations", LOCATIONS.map((l) => ({ ...l })));
console.log("Seeding divisions...");
await insertInChunks("divisions", divisionRows);
console.log("Seeding departments...");
await insertInChunks("departments", departmentRows);
console.log("Seeding teams...");
await insertInChunks("teams", teamRows);
console.log(`Seeding ${employees.length} employees...`);
await insertInChunks("employees", employees);
console.log(`Seeding ${history.length} employee_history rows...`);
await insertInChunks("employee_history", history);
console.log(`Seeding ${positions.length} open positions...`);
await insertInChunks("positions", positions);
console.log("Creating hr_admin account...");
const adminPassword = randomUUID().slice(0, 12) + "!Aa1";
const { data: adminUser, error: adminErr } = await supabase.auth.admin.createUser({
email: ADMIN_EMAIL,
password: adminPassword,
email_confirm: true,
});
if (adminErr) throw new Error(`Creating admin user failed: ${adminErr.message}`);
await supabase.from("profiles").insert({
id: adminUser.user.id,
email: ADMIN_EMAIL,
full_name: "Maximilian Stubhan",
role: "hr_admin",
});
console.log("Creating manager test account...");
const managerPassword = randomUUID().slice(0, 12) + "!Bb2";
const { data: managerUser, error: managerErr } = await supabase.auth.admin.createUser({
email: MANAGER_TEST_EMAIL,
password: managerPassword,
email_confirm: true,
});
if (managerErr) throw new Error(`Creating manager test user failed: ${managerErr.message}`);
await supabase.from("profiles").insert({
id: managerUser.user.id,
email: MANAGER_TEST_EMAIL,
full_name: "Test Manager",
role: "manager",
});
console.log("\nDone.");
console.log(`hr_admin login: ${ADMIN_EMAIL} / ${adminPassword}`);
console.log(`manager login: ${MANAGER_TEST_EMAIL} / ${managerPassword}`);
console.log("(Passwords are shown once here only — store them somewhere safe.)");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});