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:
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
NEXT_PUBLIC_SUPABASE_URL=
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=
|
||||||
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
36
README.md
Normal file
36
README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
24
actions/auth.ts
Normal file
24
actions/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export async function login(formData: FormData) {
|
||||||
|
const email = String(formData.get("email") ?? "");
|
||||||
|
const password = String(formData.get("password") ?? "");
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({ email, password });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
redirect(`/login?error=${encodeURIComponent("E-Mail oder Passwort ist falsch.")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
33
app/(app)/layout.tsx
Normal file
33
app/(app)/layout.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Sidebar } from "@/components/shell/Sidebar";
|
||||||
|
import { Topbar } from "@/components/shell/Topbar";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("full_name, email, role")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const userLabel = profile?.full_name || profile?.email || user.email || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="ml-[236px] flex flex-1 flex-col">
|
||||||
|
<Topbar userLabel={userLabel} role={profile?.role} />
|
||||||
|
<main className="flex-1 px-6 py-6">
|
||||||
|
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
225
app/(app)/page.tsx
Normal file
225
app/(app)/page.tsx
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { actionBadgeStyle } from "@/lib/colors";
|
||||||
|
import { fmtDate } from "@/lib/format";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
function isoDate(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_TEXT: Record<string, string> = {
|
||||||
|
default: "text-ink",
|
||||||
|
success: "text-success-text",
|
||||||
|
danger: "text-danger-text",
|
||||||
|
warning: "text-warning-text",
|
||||||
|
brand: "text-brand-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOT_STYLES: Record<string, string> = {
|
||||||
|
Eintritt: "bg-success-text",
|
||||||
|
Wiedereintritt: "bg-success-text",
|
||||||
|
Rückkehr: "bg-success-text",
|
||||||
|
Austritt: "bg-danger-text",
|
||||||
|
Versetzung: "bg-info-text",
|
||||||
|
Beförderung: "bg-purple-text",
|
||||||
|
Reorganisation: "bg-purple-text",
|
||||||
|
Karenz: "bg-warning-text",
|
||||||
|
Vertragsänderung: "bg-warning-text",
|
||||||
|
Stammdatenänderung: "bg-warning-text",
|
||||||
|
Gehaltsanpassung: "bg-warning-text",
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Karenz-Rückkehr" } as const;
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const todayIso = isoDate(today);
|
||||||
|
const yearStart = isoDate(new Date(today.getFullYear(), 0, 1));
|
||||||
|
const yearEnd = isoDate(new Date(today.getFullYear(), 11, 31));
|
||||||
|
const in60 = new Date(today);
|
||||||
|
in60.setDate(in60.getDate() + 60);
|
||||||
|
const in60Iso = isoDate(in60);
|
||||||
|
|
||||||
|
const [
|
||||||
|
activeCountRes,
|
||||||
|
karenzCountRes,
|
||||||
|
hiresYtdRes,
|
||||||
|
exitsYtdRes,
|
||||||
|
openPositionsRes,
|
||||||
|
fteRowsRes,
|
||||||
|
divisionsRes,
|
||||||
|
headcountRowsRes,
|
||||||
|
upcomingHiresRes,
|
||||||
|
upcomingExitsRes,
|
||||||
|
upcomingReturnsRes,
|
||||||
|
historyRes,
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from("employees_directory").select("id", { count: "exact", head: true }).in("status", ["Aktiv", "Karenz"]),
|
||||||
|
supabase.from("employees_directory").select("id", { count: "exact", head: true }).eq("status", "Karenz"),
|
||||||
|
supabase
|
||||||
|
.from("employees_directory")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.gte("entry_date", yearStart)
|
||||||
|
.lte("entry_date", yearEnd),
|
||||||
|
supabase
|
||||||
|
.from("employees_directory")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.gte("exit_date", yearStart)
|
||||||
|
.lte("exit_date", yearEnd),
|
||||||
|
supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
|
||||||
|
supabase.from("employees_directory").select("weekly_hours").in("status", ["Aktiv", "Karenz"]),
|
||||||
|
supabase.from("divisions").select("id, name"),
|
||||||
|
supabase.from("employees_directory").select("division_id").in("status", ["Aktiv", "Karenz"]),
|
||||||
|
supabase
|
||||||
|
.from("employees_directory")
|
||||||
|
.select("id, first_name, last_name, entry_date")
|
||||||
|
.eq("status", "Geplant")
|
||||||
|
.gte("entry_date", todayIso)
|
||||||
|
.lte("entry_date", in60Iso),
|
||||||
|
supabase
|
||||||
|
.from("employees_directory")
|
||||||
|
.select("id, first_name, last_name, exit_date")
|
||||||
|
.not("exit_date", "is", null)
|
||||||
|
.gte("exit_date", todayIso)
|
||||||
|
.lte("exit_date", in60Iso),
|
||||||
|
supabase
|
||||||
|
.from("employees_directory")
|
||||||
|
.select("id, first_name, last_name, karenz_return_date")
|
||||||
|
.eq("status", "Karenz")
|
||||||
|
.not("karenz_return_date", "is", null)
|
||||||
|
.gte("karenz_return_date", todayIso)
|
||||||
|
.lte("karenz_return_date", in60Iso),
|
||||||
|
supabase
|
||||||
|
.from("employee_history")
|
||||||
|
.select("id, employee_id, event_date, event_type, description")
|
||||||
|
.order("event_date", { ascending: false })
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fte = (fteRowsRes.data ?? []).reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
|
||||||
|
|
||||||
|
const headcountByDivision = new Map<string, number>();
|
||||||
|
for (const row of headcountRowsRes.data ?? []) {
|
||||||
|
if (!row.division_id) continue;
|
||||||
|
headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const divisionBars = (divisionsRes.data ?? [])
|
||||||
|
.map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 }))
|
||||||
|
.sort((a, b) => b.count - a.count);
|
||||||
|
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
|
||||||
|
|
||||||
|
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
|
||||||
|
const upcoming: UpcomingItem[] = [
|
||||||
|
...(upcomingHiresRes.data ?? []).map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
|
date: e.entry_date,
|
||||||
|
kind: "hire" as const,
|
||||||
|
})),
|
||||||
|
...(upcomingExitsRes.data ?? []).map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
|
date: e.exit_date!,
|
||||||
|
kind: "exit" as const,
|
||||||
|
})),
|
||||||
|
...(upcomingReturnsRes.data ?? []).map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
|
date: e.karenz_return_date!,
|
||||||
|
kind: "return" as const,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
.slice(0, 8);
|
||||||
|
|
||||||
|
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
|
||||||
|
const historyEmployeesRes = historyEmployeeIds.length
|
||||||
|
? await supabase.from("employees_directory").select("id, first_name, last_name").in("id", historyEmployeeIds)
|
||||||
|
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
||||||
|
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
||||||
|
|
||||||
|
const kpis = [
|
||||||
|
{ label: "Aktive Mitarbeiter:innen", value: activeCountRes.count ?? 0, tone: "default" },
|
||||||
|
{ label: "FTE", value: fte.toFixed(1), tone: "default" },
|
||||||
|
{ label: "Eintritte (Jahr)", value: hiresYtdRes.count ?? 0, tone: "success" },
|
||||||
|
{ label: "Austritte (Jahr)", value: exitsYtdRes.count ?? 0, tone: "danger" },
|
||||||
|
{ label: "In Karenz", value: karenzCountRes.count ?? 0, tone: "warning" },
|
||||||
|
{ label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
{kpis.map((kpi) => (
|
||||||
|
<div key={kpi.label} className="rounded border border-border bg-white p-4">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{kpi.label}</div>
|
||||||
|
<div className={`mt-2 text-2xl font-extrabold ${TONE_TEXT[kpi.tone]}`}>{kpi.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<div className="rounded border border-border bg-white p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-bold text-ink">Headcount nach Bereich</h2>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{divisionBars.map((d) => (
|
||||||
|
<div key={d.name}>
|
||||||
|
<div className="mb-0.5 flex justify-between text-xs text-ink-body">
|
||||||
|
<span>{d.name}</span>
|
||||||
|
<span className="font-semibold">{d.count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded bg-surface">
|
||||||
|
<div className="h-2 rounded bg-brand-500" style={{ width: `${(d.count / maxDivisionCount) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{divisionBars.length === 0 && <p className="text-sm text-ink-muted">Keine Daten vorhanden.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded border border-border bg-white p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-bold text-ink">Anstehend (60 Tage)</h2>
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{upcoming.map((item) => (
|
||||||
|
<li key={`${item.kind}-${item.id}`}>
|
||||||
|
<Link
|
||||||
|
href={`/employees/${item.id}`}
|
||||||
|
className="flex items-center justify-between py-2 text-sm hover:text-brand-700"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold text-ink">{item.label}</span>
|
||||||
|
<span className="ml-2 text-xs text-ink-muted">{KIND_LABEL[item.kind]}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-muted">{fmtDate(item.date)}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{upcoming.length === 0 && <p className="py-2 text-sm text-ink-muted">Keine anstehenden Ereignisse.</p>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded border border-border bg-white p-4">
|
||||||
|
<h2 className="mb-3 text-sm font-bold text-ink">Letzte Aktivitäten</h2>
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{(historyRes.data ?? []).map((h) => (
|
||||||
|
<li key={h.id} className="py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} />
|
||||||
|
<span className="text-sm font-semibold text-ink">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||||
|
{h.event_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-ink-muted">{h.description}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
app/(auth)/login/page.tsx
Normal file
59
app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { login } from "@/actions/auth";
|
||||||
|
|
||||||
|
type LoginPageProps = {
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||||
|
const { error } = await searchParams;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-surface px-4">
|
||||||
|
<div className="w-full max-w-sm rounded border border-border bg-white p-8 shadow-sm">
|
||||||
|
<h1 className="text-xl font-extrabold text-ink">Alpenwerk HR</h1>
|
||||||
|
<p className="mt-1 text-sm text-ink-muted">Melden Sie sich mit Ihrem Firmenkonto an.</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action={login} className="mt-6 flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="mb-1 block text-sm font-semibold text-ink">
|
||||||
|
E-Mail
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm text-ink outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="mb-1 block text-sm font-semibold text-ink">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm text-ink outline-none focus:border-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="mt-2 rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-600"
|
||||||
|
>
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
8
app/globals.css
Normal file
8
app/globals.css
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: theme("colors.surface");
|
||||||
|
color: theme("colors.ink.DEFAULT");
|
||||||
|
}
|
||||||
29
app/layout.tsx
Normal file
29
app/layout.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Nunito } from "next/font/google";
|
||||||
|
import { ToastProvider } from "@/components/ui/Toast";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const nunito = Nunito({
|
||||||
|
variable: "--font-nunito",
|
||||||
|
subsets: ["latin"],
|
||||||
|
weight: ["400", "600", "700", "800"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Alpenwerk HR",
|
||||||
|
description: "HR-Stammdaten- und Organisationsmanagement für Alpenwerk Industrie GmbH",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="de-AT" className={`${nunito.variable} h-full antialiased`}>
|
||||||
|
<body className="min-h-full flex flex-col font-sans">
|
||||||
|
<ToastProvider>{children}</ToastProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/shell/NewHireButton.tsx
Normal file
20
components/shell/NewHireButton.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
|
||||||
|
// Stub for Phase 1: the real 4-step hire wizard (§4.4) ships in a later phase.
|
||||||
|
export function NewHireButton() {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => showToast("Der Neueinstellungs-Assistent folgt in einer späteren Ausbaustufe.", "info")}
|
||||||
|
className="flex items-center gap-1.5 rounded bg-brand-500 px-3 py-1.5 text-sm font-semibold text-white hover:bg-brand-600"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Neueinstellung
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
components/shell/Sidebar.tsx
Normal file
44
components/shell/Sidebar.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BarChart3, Building2, History, LayoutGrid, Network, Users } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ href: "/", label: "Übersicht", icon: LayoutGrid },
|
||||||
|
{ href: "/employees", label: "Mitarbeiter:innen", icon: Users },
|
||||||
|
{ href: "/orgchart", label: "Organigramm", icon: Network },
|
||||||
|
{ href: "/positions", label: "Positionen & Bereiche", icon: Building2 },
|
||||||
|
{ href: "/reports", label: "Berichte", icon: BarChart3 },
|
||||||
|
{ href: "/audit", label: "Audit-Log", icon: History },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="fixed left-0 top-0 flex h-full w-[236px] flex-col border-r border-border bg-white">
|
||||||
|
<div className="flex h-14 items-center border-b border-border px-5">
|
||||||
|
<span className="text-base font-extrabold text-brand-700">Alpenwerk HR</span>
|
||||||
|
</div>
|
||||||
|
<ul className="flex-1 space-y-1 px-3 py-4">
|
||||||
|
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
|
||||||
|
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||||
|
return (
|
||||||
|
<li key={href}>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={`flex items-center gap-3 rounded px-3 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
active ? "bg-brand-100 text-brand-700" : "text-ink-body hover:bg-surface"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
components/shell/Topbar.tsx
Normal file
48
components/shell/Topbar.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { logout } from "@/actions/auth";
|
||||||
|
import { NewHireButton } from "./NewHireButton";
|
||||||
|
|
||||||
|
const TITLES: Record<string, string> = {
|
||||||
|
"/": "Übersicht",
|
||||||
|
"/employees": "Mitarbeiter:innen",
|
||||||
|
"/orgchart": "Organigramm",
|
||||||
|
"/positions": "Positionen & Bereiche",
|
||||||
|
"/reports": "Berichte",
|
||||||
|
"/audit": "Audit-Log",
|
||||||
|
};
|
||||||
|
|
||||||
|
function titleFor(pathname: string): string {
|
||||||
|
if (TITLES[pathname]) return TITLES[pathname];
|
||||||
|
const match = Object.keys(TITLES).find((key) => key !== "/" && pathname.startsWith(key));
|
||||||
|
return match ? TITLES[match] : "Alpenwerk HR";
|
||||||
|
}
|
||||||
|
|
||||||
|
type TopbarProps = {
|
||||||
|
userLabel: string;
|
||||||
|
role?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Topbar({ userLabel, role }: TopbarProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center justify-between border-b border-border bg-white px-6">
|
||||||
|
<h1 className="text-base font-bold text-ink">{titleFor(pathname)}</h1>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<NewHireButton />
|
||||||
|
<div className="flex items-center gap-2 border-l border-border pl-4 text-sm">
|
||||||
|
<span className="font-semibold text-ink">{userLabel}</span>
|
||||||
|
{role && <span className="text-xs text-ink-muted">({role === "hr_admin" ? "HR-Admin" : "Manager"})</span>}
|
||||||
|
<form action={logout}>
|
||||||
|
<button type="submit" aria-label="Abmelden" className="ml-2 rounded p-1.5 text-ink-muted hover:bg-surface">
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
components/ui/Avatar.tsx
Normal file
27
components/ui/Avatar.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { avatarColorFor } from "@/lib/colors";
|
||||||
|
import { initials } from "@/lib/format";
|
||||||
|
|
||||||
|
type AvatarProps = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
color?: string | null;
|
||||||
|
size?: "sm" | "md" | "lg";
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZE_CLASSES: Record<NonNullable<AvatarProps["size"]>, string> = {
|
||||||
|
sm: "h-7 w-7 text-xs",
|
||||||
|
md: "h-9 w-9 text-sm",
|
||||||
|
lg: "h-14 w-14 text-lg",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Avatar({ firstName, lastName, color, size = "md" }: AvatarProps) {
|
||||||
|
const bg = color ?? avatarColorFor(`${firstName}${lastName}`);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex shrink-0 items-center justify-center rounded-full font-bold text-white ${SIZE_CLASSES[size]}`}
|
||||||
|
style={{ backgroundColor: bg }}
|
||||||
|
>
|
||||||
|
{initials(firstName, lastName)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
components/ui/Lookup.tsx
Normal file
104
components/ui/Lookup.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type LookupProps<T> = {
|
||||||
|
placeholder?: string;
|
||||||
|
onSearch: (query: string) => Promise<T[]>;
|
||||||
|
renderResult: (item: T) => ReactNode;
|
||||||
|
onSelect: (item: T) => void;
|
||||||
|
minChars?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generic async search-select used by the position lookup (hire wizard),
|
||||||
|
// manager/superior lookup (create position), and employee multi-select
|
||||||
|
// (reorg workbench) in later phases.
|
||||||
|
export function Lookup<T>({ placeholder = "Suchen…", onSearch, renderResult, onSelect, minChars = 2 }: LookupProps<T>) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<T[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.trim().length < minChars) {
|
||||||
|
setResults([]);
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
onSearch(query.trim()).then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setResults(res);
|
||||||
|
setOpen(true);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, [query, minChars, onSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onClickOutside(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", onClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative">
|
||||||
|
<div className="flex items-center gap-2 rounded border border-border bg-white px-3 py-2">
|
||||||
|
<Search className="h-4 w-4 shrink-0 text-ink-muted" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full text-sm outline-none placeholder:text-ink-muted"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setResults([]);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
aria-label="Zurücksetzen"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 text-ink-muted" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-20 mt-1 max-h-64 w-full overflow-y-auto rounded border border-border bg-white shadow-lg">
|
||||||
|
{loading && <div className="px-3 py-2 text-sm text-ink-muted">Suche…</div>}
|
||||||
|
{!loading && results.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
|
||||||
|
{!loading &&
|
||||||
|
results.map((item, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(item);
|
||||||
|
setQuery("");
|
||||||
|
setResults([]);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
className="block w-full px-3 py-2 text-left text-sm hover:bg-surface"
|
||||||
|
>
|
||||||
|
{renderResult(item)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
components/ui/Modal.tsx
Normal file
46
components/ui/Modal.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type ModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
widthClassName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Modal({ open, onClose, title, children, footer, widthClassName = "max-w-lg" }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className={`flex max-h-[85vh] w-full flex-col rounded bg-white shadow-xl ${widthClassName}`}>
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||||
|
<h2 className="text-lg font-bold text-ink">{title}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="rounded p-1 text-ink-muted hover:bg-surface"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
|
||||||
|
{footer && <div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
components/ui/SegmentedControl.tsx
Normal file
28
components/ui/SegmentedControl.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type Option<T extends string> = { value: T; label: string };
|
||||||
|
|
||||||
|
type SegmentedControlProps<T extends string> = {
|
||||||
|
value: T;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
options: Option<T>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SegmentedControl<T extends string>({ value, onChange, options }: SegmentedControlProps<T>) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex rounded bg-surface p-1">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
className={`rounded px-3 py-1.5 text-sm font-semibold transition-colors ${
|
||||||
|
value === opt.value ? "bg-white text-brand-700 shadow-sm" : "text-ink-muted hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
components/ui/SlideOver.tsx
Normal file
55
components/ui/SlideOver.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type SlideOverProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
footer?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOver({ open, onClose, title, subtitle, children, footer }: SlideOverProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`fixed inset-0 z-50 ${open ? "pointer-events-auto" : "pointer-events-none"}`} aria-hidden={!open}>
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-black/40 transition-opacity ${open ? "opacity-100" : "opacity-0"}`}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute right-0 top-0 flex h-full w-full max-w-md flex-col bg-white shadow-xl transition-transform duration-200 ${
|
||||||
|
open ? "translate-x-0" : "translate-x-full"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between border-b border-border px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-ink">{title}</h2>
|
||||||
|
{subtitle && <p className="text-sm text-ink-muted">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="rounded p-1 text-ink-muted hover:bg-surface"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-4">{children}</div>
|
||||||
|
{footer && <div className="flex items-center justify-end gap-2 border-t border-border px-6 py-4">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
components/ui/StatusChip.tsx
Normal file
19
components/ui/StatusChip.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { STATUS_STYLES } from "@/lib/colors";
|
||||||
|
import { fmtDate } from "@/lib/format";
|
||||||
|
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||||
|
|
||||||
|
type StatusChipProps = {
|
||||||
|
status: EmploymentStatus;
|
||||||
|
entryDate?: string | null; // shown as "Eintritt {date}" when status is Geplant
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusChip({ status, entryDate }: StatusChipProps) {
|
||||||
|
const label = status === "Geplant" && entryDate ? `Eintritt ${fmtDate(entryDate)}` : status;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center whitespace-nowrap rounded-full px-2.5 py-0.5 text-xs font-semibold ${STATUS_STYLES[status]}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
components/ui/Toast.tsx
Normal file
48
components/ui/Toast.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
type ToastVariant = "success" | "error" | "info";
|
||||||
|
type ToastItem = { id: number; message: string; variant: ToastVariant };
|
||||||
|
type ToastContextValue = { showToast: (message: string, variant?: ToastVariant) => void };
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||||
|
|
||||||
|
const VARIANT_STYLES: Record<ToastVariant, string> = {
|
||||||
|
success: "bg-success-text",
|
||||||
|
error: "bg-danger-solid",
|
||||||
|
info: "bg-info-text",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
|
||||||
|
const showToast = useCallback((message: string, variant: ToastVariant = "success") => {
|
||||||
|
const id = Date.now() + Math.random();
|
||||||
|
setToasts((prev) => [...prev, { id, message, variant }]);
|
||||||
|
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ showToast }}>
|
||||||
|
{children}
|
||||||
|
<div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
|
||||||
|
{toasts.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
role="status"
|
||||||
|
className={`pointer-events-auto rounded px-4 py-2 text-sm font-semibold text-white shadow-lg ${VARIANT_STYLES[t.variant]}`}
|
||||||
|
>
|
||||||
|
{t.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast(): ToastContextValue {
|
||||||
|
const ctx = useContext(ToastContext);
|
||||||
|
if (!ctx) throw new Error("useToast must be used within a ToastProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
63
lib/colors.ts
Normal file
63
lib/colors.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { EmploymentStatus } from "./supabase/types";
|
||||||
|
|
||||||
|
const AVATAR_PALETTE = [
|
||||||
|
"#d6046e",
|
||||||
|
"#00666d",
|
||||||
|
"#5c2e91",
|
||||||
|
"#835b00",
|
||||||
|
"#0e700e",
|
||||||
|
"#b0035a",
|
||||||
|
"#2b6cb0",
|
||||||
|
"#a30354",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Stable per-employee avatar color when employees.avatar_color isn't set.
|
||||||
|
export function avatarColorFor(seed: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
hash = (hash << 5) - hash + seed.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return AVATAR_PALETTE[Math.abs(hash) % AVATAR_PALETTE.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_STYLES: Record<EmploymentStatus, string> = {
|
||||||
|
Aktiv: "bg-success-bg text-success-text",
|
||||||
|
Karenz: "bg-warning-bg text-warning-text",
|
||||||
|
Geplant: "bg-brand-100 text-brand-700",
|
||||||
|
Ausgetreten: "bg-danger-bg text-danger-text",
|
||||||
|
};
|
||||||
|
|
||||||
|
type ColorCategory = "success" | "danger" | "warning" | "info" | "purple" | "brand";
|
||||||
|
|
||||||
|
const CATEGORY_STYLES: Record<ColorCategory, string> = {
|
||||||
|
success: "bg-success-bg text-success-text",
|
||||||
|
danger: "bg-danger-bg text-danger-text",
|
||||||
|
warning: "bg-warning-bg text-warning-text",
|
||||||
|
info: "bg-info-bg text-info-text",
|
||||||
|
purple: "bg-purple-bg text-purple-text",
|
||||||
|
brand: "bg-brand-100 text-brand-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Audit-log / activity-feed action -> badge color, per the action list in
|
||||||
|
// supabase/schema.sql's audit_log comment.
|
||||||
|
const ACTION_CATEGORY: Record<string, ColorCategory> = {
|
||||||
|
Neueinstellung: "success",
|
||||||
|
Wiedereinstellung: "success",
|
||||||
|
Rückkehr: "success",
|
||||||
|
Austritt: "danger",
|
||||||
|
Versetzung: "info",
|
||||||
|
Ausschreibung: "info",
|
||||||
|
"Interne Besetzung": "info",
|
||||||
|
Beförderung: "purple",
|
||||||
|
Reorganisation: "purple",
|
||||||
|
"Reorganisation rückgängig": "purple",
|
||||||
|
Karenz: "warning",
|
||||||
|
Vertragsänderung: "warning",
|
||||||
|
Stammdatenänderung: "warning",
|
||||||
|
Gehaltsanpassung: "warning",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function actionBadgeStyle(action: string): string {
|
||||||
|
return CATEGORY_STYLES[ACTION_CATEGORY[action] ?? "brand"];
|
||||||
|
}
|
||||||
64
lib/format.ts
Normal file
64
lib/format.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
const dateFormatter = new Intl.DateTimeFormat("de-AT", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const eurFormatter = new Intl.NumberFormat("de-AT", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "EUR",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function fmtDate(date: string | Date | null | undefined): string {
|
||||||
|
if (!date) return "–";
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
if (Number.isNaN(d.getTime())) return "–";
|
||||||
|
return dateFormatter.format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtEUR(amount: number | null | undefined): string {
|
||||||
|
if (amount === null || amount === undefined) return "••• (ausgeblendet)";
|
||||||
|
return eurFormatter.format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initials(firstName: string, lastName: string): string {
|
||||||
|
const a = firstName.trim().charAt(0).toUpperCase();
|
||||||
|
const b = lastName.trim().charAt(0).toUpperCase();
|
||||||
|
return `${a}${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtAge(birthDate: string | Date): number {
|
||||||
|
const d = typeof birthDate === "string" ? new Date(birthDate) : birthDate;
|
||||||
|
const today = new Date();
|
||||||
|
let age = today.getFullYear() - d.getFullYear();
|
||||||
|
const hasHadBirthdayThisYear =
|
||||||
|
today.getMonth() > d.getMonth() ||
|
||||||
|
(today.getMonth() === d.getMonth() && today.getDate() >= d.getDate());
|
||||||
|
if (!hasHadBirthdayThisYear) age -= 1;
|
||||||
|
return age;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tenure(entryDate: string | Date, endDate?: string | Date | null): string {
|
||||||
|
const start = typeof entryDate === "string" ? new Date(entryDate) : entryDate;
|
||||||
|
const end = endDate ? (typeof endDate === "string" ? new Date(endDate) : endDate) : new Date();
|
||||||
|
|
||||||
|
let years = end.getFullYear() - start.getFullYear();
|
||||||
|
let months = end.getMonth() - start.getMonth();
|
||||||
|
if (end.getDate() < start.getDate()) months -= 1;
|
||||||
|
if (months < 0) {
|
||||||
|
years -= 1;
|
||||||
|
months += 12;
|
||||||
|
}
|
||||||
|
if (years < 0) return "0 Monate";
|
||||||
|
|
||||||
|
const yearPart = years > 0 ? `${years} ${years === 1 ? "Jahr" : "Jahre"}` : "";
|
||||||
|
const monthPart = months > 0 ? `${months} ${months === 1 ? "Monat" : "Monate"}` : "";
|
||||||
|
if (yearPart && monthPart) return `${yearPart}, ${monthPart}`;
|
||||||
|
return yearPart || monthPart || "unter 1 Monat";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysBetween(a: string | Date, b: string | Date = new Date()): number {
|
||||||
|
const start = typeof a === "string" ? new Date(a) : a;
|
||||||
|
const end = typeof b === "string" ? new Date(b) : b;
|
||||||
|
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
16
lib/supabase/admin.ts
Normal file
16
lib/supabase/admin.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import type { Database } from "./types";
|
||||||
|
|
||||||
|
// Service-role client: bypasses RLS entirely. Server-only — never import this
|
||||||
|
// from a Client Component or anything bundled for the browser.
|
||||||
|
export function createAdminClient() {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
throw new Error("createAdminClient must never be called in the browser");
|
||||||
|
}
|
||||||
|
|
||||||
|
return createSupabaseClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||||
|
{ auth: { autoRefreshToken: false, persistSession: false } }
|
||||||
|
);
|
||||||
|
}
|
||||||
11
lib/supabase/client.ts
Normal file
11
lib/supabase/client.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
import type { Database } from "./types";
|
||||||
|
|
||||||
|
// For use in Client Components that need interactivity (filters, live
|
||||||
|
// hints, etc). Server Components/Actions should use lib/supabase/server.ts.
|
||||||
|
export function createClient() {
|
||||||
|
return createBrowserClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
);
|
||||||
|
}
|
||||||
31
lib/supabase/server.ts
Normal file
31
lib/supabase/server.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import type { Database } from "./types";
|
||||||
|
|
||||||
|
// For use in Server Components and Server Actions. Respects the signed-in
|
||||||
|
// user's session, so all reads/writes go through RLS as that user.
|
||||||
|
export async function createClient() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
return createServerClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Called from a Server Component during render — safe to ignore
|
||||||
|
// because proxy.ts refreshes the session cookie on every request.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
286
lib/supabase/types.ts
Normal file
286
lib/supabase/types.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
// Hand-written to match supabase/schema.sql (no DB connection string available to
|
||||||
|
// run `supabase gen types typescript` in this environment — regenerate from the
|
||||||
|
// live project once you have the Supabase CLI linked).
|
||||||
|
|
||||||
|
export type EmploymentStatus = "Aktiv" | "Karenz" | "Geplant" | "Ausgetreten";
|
||||||
|
export type EmploymentType = "Vollzeit" | "Teilzeit";
|
||||||
|
export type ContractType = "unbefristet" | "befristet";
|
||||||
|
export type PaygradeType = "A" | "B" | "C" | "D" | "E" | "F";
|
||||||
|
export type SourceType = "Intern" | "Extern";
|
||||||
|
export type GenderType = "m" | "w";
|
||||||
|
export type ProfileRole = "hr_admin" | "manager";
|
||||||
|
export type HistoryEventType =
|
||||||
|
| "Eintritt"
|
||||||
|
| "Beförderung"
|
||||||
|
| "Versetzung"
|
||||||
|
| "Karenz"
|
||||||
|
| "Vertragsänderung"
|
||||||
|
| "Stammdatenänderung"
|
||||||
|
| "Austritt"
|
||||||
|
| "Wiedereintritt"
|
||||||
|
| "Reorganisation"
|
||||||
|
| "Gehaltsanpassung"
|
||||||
|
| "Rückkehr";
|
||||||
|
export type PositionStatus = "open" | "filled";
|
||||||
|
export type ReorgMoveKind = "emp" | "team" | "abt" | "dept";
|
||||||
|
|
||||||
|
// @supabase/postgrest-js requires every table/view to carry a Relationships
|
||||||
|
// array (used for typed embedded selects) — left empty since no code in this
|
||||||
|
// app relies on nested/embedded resource selects.
|
||||||
|
type NoRelationships = { Relationships: [] };
|
||||||
|
|
||||||
|
export type Database = {
|
||||||
|
public: {
|
||||||
|
Tables: {
|
||||||
|
divisions: NoRelationships & {
|
||||||
|
Row: { id: string; org_number: string; name: string };
|
||||||
|
Insert: { id?: string; org_number: string; name: string };
|
||||||
|
Update: Partial<{ id: string; org_number: string; name: string }>;
|
||||||
|
};
|
||||||
|
departments: NoRelationships & {
|
||||||
|
Row: { id: string; org_number: string; name: string; division_id: string };
|
||||||
|
Insert: { id?: string; org_number: string; name: string; division_id: string };
|
||||||
|
Update: Partial<{ id: string; org_number: string; name: string; division_id: string }>;
|
||||||
|
};
|
||||||
|
teams: NoRelationships & {
|
||||||
|
Row: { id: string; org_number: string; name: string; department_id: string };
|
||||||
|
Insert: { id?: string; org_number: string; name: string; department_id: string };
|
||||||
|
Update: Partial<{ id: string; org_number: string; name: string; department_id: string }>;
|
||||||
|
};
|
||||||
|
locations: NoRelationships & {
|
||||||
|
Row: { id: string; name: string; country: string };
|
||||||
|
Insert: { id?: string; name: string; country: string };
|
||||||
|
Update: Partial<{ id: string; name: string; country: string }>;
|
||||||
|
};
|
||||||
|
profiles: NoRelationships & {
|
||||||
|
Row: { id: string; email: string; full_name: string | null; role: ProfileRole; created_at: string };
|
||||||
|
Insert: { id: string; email: string; full_name?: string | null; role?: ProfileRole; created_at?: string };
|
||||||
|
Update: Partial<{ id: string; email: string; full_name: string | null; role: ProfileRole; created_at: string }>;
|
||||||
|
};
|
||||||
|
employees: NoRelationships & {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
personnel_number: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
gender: GenderType;
|
||||||
|
birth_date: string;
|
||||||
|
sv_nummer: string | null;
|
||||||
|
nationality: string;
|
||||||
|
address: string | null;
|
||||||
|
address_country: string | null;
|
||||||
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
|
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: EmploymentType;
|
||||||
|
weekly_hours: number;
|
||||||
|
monthly_salary_gross: number;
|
||||||
|
contract_type: ContractType;
|
||||||
|
contract_end_date: string | null;
|
||||||
|
paygrade: PaygradeType;
|
||||||
|
source: SourceType;
|
||||||
|
status: EmploymentStatus;
|
||||||
|
entry_date: string;
|
||||||
|
exit_date: string | null;
|
||||||
|
exit_reason: string | null;
|
||||||
|
karenz_return_date: string | null;
|
||||||
|
avatar_color: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
gender: GenderType;
|
||||||
|
birth_date: string;
|
||||||
|
sv_nummer?: string | null;
|
||||||
|
nationality?: string;
|
||||||
|
address?: string | null;
|
||||||
|
address_country?: string | null;
|
||||||
|
email: string;
|
||||||
|
phone?: string | null;
|
||||||
|
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?: EmploymentType;
|
||||||
|
weekly_hours?: number;
|
||||||
|
monthly_salary_gross: number;
|
||||||
|
contract_type?: ContractType;
|
||||||
|
contract_end_date?: string | null;
|
||||||
|
paygrade?: PaygradeType;
|
||||||
|
source?: SourceType;
|
||||||
|
status?: EmploymentStatus;
|
||||||
|
entry_date: string;
|
||||||
|
exit_date?: string | null;
|
||||||
|
exit_reason?: string | null;
|
||||||
|
karenz_return_date?: string | null;
|
||||||
|
avatar_color?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
};
|
||||||
|
Update: Partial<Database["public"]["Tables"]["employees"]["Insert"]>;
|
||||||
|
};
|
||||||
|
employee_history: NoRelationships & {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
employee_id: string;
|
||||||
|
event_date: string;
|
||||||
|
event_type: HistoryEventType;
|
||||||
|
description: string;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
employee_id: string;
|
||||||
|
event_date: string;
|
||||||
|
event_type: HistoryEventType;
|
||||||
|
description: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
|
||||||
|
};
|
||||||
|
positions: NoRelationships & {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
position_number: string;
|
||||||
|
title: string;
|
||||||
|
team_id: string;
|
||||||
|
division_id: string;
|
||||||
|
is_lead: boolean;
|
||||||
|
reports_to_employee_id: string | null;
|
||||||
|
status: PositionStatus;
|
||||||
|
created_at: string;
|
||||||
|
filled_at: string | null;
|
||||||
|
filled_by_employee_id: string | null;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
position_number?: string;
|
||||||
|
title: string;
|
||||||
|
team_id: string;
|
||||||
|
division_id?: string;
|
||||||
|
is_lead?: boolean;
|
||||||
|
reports_to_employee_id?: string | null;
|
||||||
|
status?: PositionStatus;
|
||||||
|
created_at?: string;
|
||||||
|
filled_at?: string | null;
|
||||||
|
filled_by_employee_id?: string | null;
|
||||||
|
};
|
||||||
|
Update: Partial<Database["public"]["Tables"]["positions"]["Insert"]>;
|
||||||
|
};
|
||||||
|
hire_drafts: NoRelationships & {
|
||||||
|
Row: { id: string; created_by: string | null; step: number; payload: Record<string, unknown>; updated_at: string };
|
||||||
|
Insert: { id?: string; created_by?: string | null; step?: number; payload: Record<string, unknown>; updated_at?: string };
|
||||||
|
Update: Partial<Database["public"]["Tables"]["hire_drafts"]["Insert"]>;
|
||||||
|
};
|
||||||
|
saved_reports: NoRelationships & {
|
||||||
|
Row: { id: string; created_by: string | null; name: string; config: Record<string, unknown>; created_at: string };
|
||||||
|
Insert: { id?: string; created_by?: string | null; name: string; config: Record<string, unknown>; created_at?: string };
|
||||||
|
Update: Partial<Database["public"]["Tables"]["saved_reports"]["Insert"]>;
|
||||||
|
};
|
||||||
|
audit_log: NoRelationships & {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
occurred_at: string;
|
||||||
|
actor_user_id: string | null;
|
||||||
|
actor_name: string;
|
||||||
|
action: string;
|
||||||
|
target_label: string;
|
||||||
|
target_employee_id: string | null;
|
||||||
|
details: string | null;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
occurred_at?: string;
|
||||||
|
actor_user_id?: string | null;
|
||||||
|
actor_name: string;
|
||||||
|
action: string;
|
||||||
|
target_label: string;
|
||||||
|
target_employee_id?: string | null;
|
||||||
|
details?: string | null;
|
||||||
|
};
|
||||||
|
Update: Partial<Database["public"]["Tables"]["audit_log"]["Insert"]>;
|
||||||
|
};
|
||||||
|
reorg_scenarios: NoRelationships & {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
effective_date: string;
|
||||||
|
created_by: string | null;
|
||||||
|
applied: boolean;
|
||||||
|
applied_at: string | null;
|
||||||
|
undo_snapshot: Record<string, unknown> | null;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
Insert: {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
effective_date: string;
|
||||||
|
created_by?: string | null;
|
||||||
|
applied?: boolean;
|
||||||
|
applied_at?: string | null;
|
||||||
|
undo_snapshot?: Record<string, unknown> | null;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
Update: Partial<Database["public"]["Tables"]["reorg_scenarios"]["Insert"]>;
|
||||||
|
};
|
||||||
|
reorg_moves: NoRelationships & {
|
||||||
|
Row: { id: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
|
||||||
|
Insert: { id?: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
|
||||||
|
Update: Partial<Database["public"]["Tables"]["reorg_moves"]["Insert"]>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
Views: {
|
||||||
|
employees_directory: NoRelationships & {
|
||||||
|
Row: {
|
||||||
|
id: string;
|
||||||
|
personnel_number: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
gender: GenderType;
|
||||||
|
birth_date: string;
|
||||||
|
sv_nummer: string | null;
|
||||||
|
nationality: string;
|
||||||
|
address: string | null;
|
||||||
|
address_country: string | null;
|
||||||
|
email: string;
|
||||||
|
phone: string | null;
|
||||||
|
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: EmploymentType;
|
||||||
|
weekly_hours: number;
|
||||||
|
monthly_salary_gross: number | null; // masked to null for non-admin sessions
|
||||||
|
contract_type: ContractType;
|
||||||
|
contract_end_date: string | null;
|
||||||
|
paygrade: PaygradeType;
|
||||||
|
source: SourceType;
|
||||||
|
status: EmploymentStatus;
|
||||||
|
entry_date: string;
|
||||||
|
exit_date: string | null;
|
||||||
|
exit_reason: string | null;
|
||||||
|
karenz_return_date: string | null;
|
||||||
|
avatar_color: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
Functions: Record<string, never>;
|
||||||
|
};
|
||||||
|
};
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6947
package-lock.json
generated
Normal file
6947
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
Normal file
30
package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "alpenwerk-hr",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/ssr": "^0.12.1",
|
||||||
|
"@supabase/supabase-js": "^2.110.3",
|
||||||
|
"lucide-react": "^1.24.0",
|
||||||
|
"next": "16.2.10",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"autoprefixer": "^10.5.2",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.2.10",
|
||||||
|
"postcss": "^8.5.19",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
51
proxy.ts
Normal file
51
proxy.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
// Next.js 16 renamed Middleware to Proxy (same mechanism, new filename/export).
|
||||||
|
// This performs the optimistic auth check: redirect unauthenticated users to
|
||||||
|
// /login, and signed-in users away from /login. Real authorization (hr_admin
|
||||||
|
// vs manager) is enforced server-side via RLS, not here.
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
let response = NextResponse.next({ request });
|
||||||
|
|
||||||
|
const supabase = createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
|
||||||
|
response = NextResponse.next({ request });
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const isLoginRoute = request.nextUrl.pathname.startsWith("/login");
|
||||||
|
|
||||||
|
if (!user && !isLoginRoute) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = "/login";
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && isLoginRoute) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = "/";
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||||
|
};
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
360
supabase/schema.sql
Normal file
360
supabase/schema.sql
Normal 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
708
supabase/seed.ts
Normal 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);
|
||||||
|
});
|
||||||
44
tailwind.config.ts
Normal file
44
tailwind.config.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
// Design tokens from spec §7
|
||||||
|
const config: Config = {
|
||||||
|
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
50: "#fdf3f8",
|
||||||
|
100: "#fdeaf3",
|
||||||
|
200: "#f6cfe2",
|
||||||
|
500: "#d6046e",
|
||||||
|
600: "#b0035a",
|
||||||
|
700: "#a30354",
|
||||||
|
},
|
||||||
|
surface: "#f9f1f5",
|
||||||
|
border: {
|
||||||
|
DEFAULT: "#eedde6",
|
||||||
|
subtle: "#f7e8ef",
|
||||||
|
},
|
||||||
|
ink: {
|
||||||
|
DEFAULT: "#2d1c26",
|
||||||
|
body: "#503b47",
|
||||||
|
muted: "#7a636f",
|
||||||
|
},
|
||||||
|
success: { bg: "#dff6dd", text: "#0e700e" },
|
||||||
|
danger: { bg: "#fde7e9", text: "#b10e1c", solid: "#c50f1f" },
|
||||||
|
warning: { bg: "#fff4ce", text: "#835b00" },
|
||||||
|
info: { bg: "#d7f0f0", text: "#00666d" },
|
||||||
|
purple: { bg: "#f0ecf7", text: "#5c2e91" },
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
DEFAULT: "8px",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-nunito)", "system-ui", "sans-serif"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user