Files
alpenwerk-hr/supabase/seed.ts
Maximilian Stubhan 8282d7f581 Rename Karenz to Langzeitabwesenheit and record its type
Karenz was doing duty as the name for every kind of extended absence, but
the cases behave differently in payroll and reporting — Wochenhilfe, a
Präsenzdienst, a long sick leave and a sabbatical are not the same thing.
The concept is now called Langzeitabwesenheit and carries which kind it is.

- employees.absence_type, constrained to the thirteen kinds. start_karenz
  stores it on both paths (written straight away, or parked in the
  pending_org_changes payload when the absence starts later);
  record_karenz_return and the karenz_return branch of
  apply_due_pending_changes clear it, so a returned employee does not keep
  looking like they are still away. It also reaches employee_history, the
  audit log and the employee export.
- The status enum value stays 'Karenz'. Postgres can rename an enum value in
  place, but every stored function body that spells it would then reference
  a value that no longer exists — a dozen functions across fifteen
  migrations, rewritten for a label. The mapping lives in lib/absence.ts
  instead, which is the single place the UI reads the display name from.
- Where a kind is recorded the chip shows it — "Bildungskarenz" says more
  than "Langzeitabwesenheit". Absences predating the field have none and
  fall back to the generic name rather than to a guess, and a value outside
  the list is dropped rather than echoed into the UI.
- The export prints the display name, not the raw enum: a payroll hand-off
  reading "Karenz" for what the app calls Langzeitabwesenheit only causes
  questions. Audit filter options keep their stored values and change only
  their labels.
- The seed spreads the twelve absences across the kinds; all of them being
  Karenz would leave any breakdown by kind invisible.
2026-07-25 14:34:28 +02:00

726 lines
29 KiB
TypeScript

// 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";
// Explicit .ts extension: this file is run directly by Node (type-stripping,
// ESM), where an extensionless relative import does not resolve.
import { svnrCheckDigit } from "../lib/svnr.ts";
import { ABSENCE_TYPES } from "../lib/absence.ts";
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 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"];
// Home address should be plausible for the employee's actual work location,
// not a one-size-fits-all Vienna postal code regardless of where they're
// based (found during the consolidation review).
const HOME_LOCALE_BY_LOCATION: Record<string, { city: string; postal: () => string }> = {
"Wien-Hernals": { city: "Wien", postal: () => String(randInt(1100, 1230)) },
Wolkersdorf: { city: "Wolkersdorf", postal: () => "2120" },
Köln: { city: "Köln", postal: () => String(randInt(50667, 51149)) },
Brünn: { city: "Brno", postal: () => `${randInt(600, 664)} ${randInt(10, 99)}` },
Ljubljana: { city: "Ljubljana", postal: () => "1000" },
};
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;
postal_code: string;
city: 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;
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_start_date: string | null;
karenz_return_date: string | null;
absence_type: 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;
}
// Used a random four-digit prefix before, so the check digit was right only
// by chance — which the SVNR validation trigger now rejects outright. The
// serial is still random; only the check digit is computed for it.
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 tail = `${dd}${mm}${yy}`;
// Not every serial yields a usable check digit (a weighted sum of 11 is
// skipped rather than wrapped), so draw until one does.
for (;;) {
const serial = String(randInt(1, 999)).padStart(3, "0");
const check = svnrCheckDigit(`${serial}0${tail}`);
if (check !== null) return `${serial}${check} ${tail}`;
}
}
function birthDateForAge(age: number): Date {
const year = TODAY.getFullYear() - age;
return new Date(year, randInt(0, 11), randInt(1, 28));
}
function paygradeForIc(): EmployeeRow["paygrade"] {
return weightedPick<EmployeeRow["paygrade"]>([
["A", 15],
["B", 35],
["C", 30],
["D", 20],
]);
}
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);
const homeLocale = HOME_LOCALE_BY_LOCATION[location.name];
return {
id: randomUUID(),
first_name: firstName,
last_name: lastName,
gender,
nationality,
address: `${pick(STREETS)} ${randInt(1, 90)}`,
postal_code: homeLocale.postal(),
city: homeLocale.city,
address_country: addressCountryFor(nationality),
email: makeEmail(firstName, lastName),
phone: `+43 664 ${randInt(1000000, 9999999)}`,
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: { 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,
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_start_date: null,
karenz_return_date: null,
absence_type: 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}`,
});
}
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),
{ paygrade: "F" }
);
const gfAssistant = finalizeEmployee(
newHireBase("Assistenz der Geschäftsführung", 3, false, null, gfDivisionId, ceo.id),
{ 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),
{ 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),
{ paygrade: "E" }
);
employees.push(teamLead);
for (let i = 0; i < size - 1; i++) {
const jobTitle = pick(team.icTitles);
const paygrade = paygradeForIc();
const ic = finalizeEmployee(
newHireBase(jobTitle, 3, false, teamId, divisionId, teamLead.id),
{ 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 Langzeitabwesenheiten, über die Arten gestreut statt alle als Karenz —
// die Auswertung nach Art ist sonst nicht zu sehen.
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));
const absenceType = pick(ABSENCE_TYPES);
e.status = "Karenz";
e.karenz_start_date = isoDate(karenzStart);
e.karenz_return_date = isoDate(returnDate);
e.absence_type = absenceType;
history.push({
employee_id: e.id,
event_date: isoDate(karenzStart),
event_type: "Karenz",
description: `${absenceType}, geplante Rückkehr am ${isoDate(returnDate)}`,
});
}
// ~3 Geplant (future entry). A person who hasn't started yet can't already
// have a Beförderung or other history predating that future entry date —
// found during the consolidation review that reassigning an already-
// finalized IC to Geplant only patched their Eintritt row's date, leaving
// any earlier-generated history (e.g. a Beförderung) still on the record
// with a date before the (now future) entry_date. Fixed by dropping every
// history row for that employee except Eintritt, then moving Eintritt to
// the new future date.
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);
for (let hi = history.length - 1; hi >= 0; hi--) {
if (history[hi].employee_id === e.id && history[hi].event_type !== "Eintritt") history.splice(hi, 1);
}
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);
// The app is HR-only now (see docs/decisions/0001-hr-only-access.md) — no
// second "manager" role exists to seed a test account for. This is the
// one deliberate, explicit bootstrap grant of HR access (not an automatic
// one): every other new profile row defaults to is_active = false and
// must be activated by an existing HR user (§2.3).
console.log("Creating initial HR account...");
const hrPassword = randomUUID().slice(0, 12) + "!Aa1";
const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({
email: ADMIN_EMAIL,
password: hrPassword,
email_confirm: true,
});
if (hrErr) throw new Error(`Creating HR user failed: ${hrErr.message}`);
await supabase.from("profiles").insert({
id: hrUser.user.id,
email: ADMIN_EMAIL,
full_name: "Maximilian Stubhan",
role: "hr",
is_active: true,
});
console.log("\nDone.");
console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`);
console.log("(Password is shown once here only — store it somewhere safe.)");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});