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.
726 lines
29 KiB
TypeScript
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);
|
|
});
|