Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite
Reworks the app from a two-role (hr_admin/manager) model to a single HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/ reorg RPCs to actually defer future-dated changes via a new pending_org_changes table instead of writing them immediately (applied by a daily Vercel Cron route), makes reorg undo append-only instead of deleting history, adds Karenz-return and history-date integrity guards, deprecates the salary column, and adds explicit schema grants + perf indexes needed to run against a fresh (non-hosted) Postgres instance. Adds vitest unit + integration test suites (the latter against a real local Supabase instance) covering all of the above, plus lint/typecheck/ build wiring (`npm run check`).
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
|
||||
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
|
||||
# project's env vars). Generate with e.g. `openssl rand -hex 32`.
|
||||
CRON_SECRET=
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -40,3 +40,13 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# supabase CLI local dev (generated, project-machine-specific)
|
||||
/supabase/.branches
|
||||
/supabase/.temp
|
||||
/supabase/snippets
|
||||
|
||||
# playwright
|
||||
/playwright-report
|
||||
/test-results
|
||||
/blob-report
|
||||
|
||||
@@ -31,7 +31,6 @@ export async function hireEmployee(payload: {
|
||||
contract_end_date?: string;
|
||||
employment_type?: "Vollzeit" | "Teilzeit";
|
||||
weekly_hours?: number;
|
||||
monthly_salary_gross: number;
|
||||
paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
|
||||
source: "Intern" | "Extern";
|
||||
}): Promise<ActionResult & { employeeId?: string }> {
|
||||
@@ -66,7 +65,6 @@ export async function promoteEmployee(payload: {
|
||||
employee_id: string;
|
||||
effective_date: string;
|
||||
new_title: string;
|
||||
new_salary: number;
|
||||
new_paygrade?: "A" | "B" | "C" | "D" | "E" | "F";
|
||||
}): Promise<ActionResult> {
|
||||
return callRpc("promote_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||
@@ -119,7 +117,7 @@ export type EmployeeSearchResult = { id: string; first_name: string; last_name:
|
||||
export async function searchActiveEmployees(query: string): Promise<EmployeeSearchResult[]> {
|
||||
const supabase = await createClient();
|
||||
let q = supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, team_id")
|
||||
.in("status", ["Aktiv", "Karenz"])
|
||||
.limit(20);
|
||||
|
||||
@@ -43,7 +43,7 @@ export type SuperiorSearchResult = { id: string; first_name: string; last_name:
|
||||
export async function searchSuperiors(query: string, forLeadPosition: boolean): Promise<SuperiorSearchResult[]> {
|
||||
const supabase = await createClient();
|
||||
let q = supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, division_id")
|
||||
.eq("status", "Aktiv")
|
||||
.limit(20);
|
||||
|
||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { AuditFilters } from "@/components/audit/AuditFilters";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -8,22 +8,16 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
const { data: profile } = await supabase.from("profiles").select("role").eq("id", user!.id).single();
|
||||
const canEdit = profile?.role === "hr_admin";
|
||||
|
||||
const { data: employee } = await supabase.from("employees_directory").select("*").eq("id", id).single();
|
||||
const { data: employee } = await supabase.from("employees").select("*").eq("id", id).single();
|
||||
if (!employee) notFound();
|
||||
|
||||
const [{ data: manager }, { data: directReports }, { data: history }, { data: divisions }, { data: departments }, { data: teams }, { data: locations }, { data: openPositions }] =
|
||||
await Promise.all([
|
||||
employee.manager_id
|
||||
? supabase.from("employees_directory").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single()
|
||||
? supabase.from("employees").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single()
|
||||
: Promise.resolve({ data: null }),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, status")
|
||||
.eq("manager_id", id)
|
||||
.order("last_name"),
|
||||
@@ -46,7 +40,6 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
teams={teams ?? []}
|
||||
locations={locations ?? []}
|
||||
openPositions={openPositions ?? []}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
const to = from + PAGE_SIZE - 1;
|
||||
|
||||
let query = supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select(
|
||||
"id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status",
|
||||
{ count: "exact" }
|
||||
|
||||
@@ -13,25 +13,27 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
} = 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 canEdit = profile?.role === "hr_admin";
|
||||
// Defense in depth: proxy.ts already redirects any non-active-HR session
|
||||
// away before this layout ever renders. Re-checking here means a gap in
|
||||
// the proxy matcher (or a future route added outside it) still fails
|
||||
// closed instead of silently granting access — see docs/security.md.
|
||||
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle();
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login");
|
||||
|
||||
const userLabel = profile?.full_name || profile?.email || user.email || "";
|
||||
const userLabel = profile.full_name || profile.email || user.email || "";
|
||||
|
||||
const [openPositions, locationsRes, draftsRes] = canEdit
|
||||
? await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("locations").select("id, name, country").order("name"),
|
||||
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
||||
])
|
||||
: [[], { data: [] }, { data: [] }];
|
||||
const [openPositions, locationsRes, draftsRes] = await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("locations").select("id, name, country").order("name"),
|
||||
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="ml-[236px] flex flex-1 flex-col">
|
||||
<Topbar userLabel={userLabel} role={profile?.role} canEdit={canEdit} />
|
||||
<Topbar userLabel={userLabel} />
|
||||
<main className="flex-1 px-6 py-6">
|
||||
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
|
||||
</main>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function OrgChartPage() {
|
||||
{ data: reorgScenarios },
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level")
|
||||
.in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
|
||||
@@ -67,36 +67,36 @@ export default async function DashboardPage() {
|
||||
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").select("id", { count: "exact", head: true }).in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("employees").select("id", { count: "exact", head: true }).eq("status", "Karenz"),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("entry_date", yearStart)
|
||||
.lte("entry_date", yearEnd),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.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("employees").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").select("division_id").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, entry_date")
|
||||
.eq("status", "Geplant")
|
||||
.gte("entry_date", todayIso)
|
||||
.lte("entry_date", in60Iso),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.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")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, karenz_return_date")
|
||||
.eq("status", "Karenz")
|
||||
.not("karenz_return_date", "is", null)
|
||||
@@ -148,7 +148,7 @@ export default async function DashboardPage() {
|
||||
|
||||
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)
|
||||
? await supabase.from("employees").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}`]));
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ export default async function PositionsPage() {
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
supabase.from("departments").select("*"),
|
||||
supabase.from("teams").select("*"),
|
||||
supabase.from("employees_directory").select("team_id, division_id, weekly_hours").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("employees").select("team_id, division_id, weekly_hours").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, team_id, division_id, org_level, is_lead")
|
||||
.eq("status", "Aktiv")
|
||||
.or("is_lead.eq.true,org_level.eq.0"),
|
||||
|
||||
@@ -47,9 +47,9 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
};
|
||||
|
||||
let query = supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select(
|
||||
"id, first_name, last_name, job_title, division_id, team_id, location_id, status, employment_type, contract_type, entry_date, exit_date, weekly_hours, monthly_salary_gross, source, paygrade, birth_date, gender"
|
||||
"id, first_name, last_name, job_title, division_id, team_id, location_id, status, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender"
|
||||
);
|
||||
|
||||
if (params.division) query = query.eq("division_id", params.division);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { login } from "@/actions/auth";
|
||||
import { login, logout } from "@/actions/auth";
|
||||
|
||||
type LoginPageProps = {
|
||||
searchParams: Promise<{ error?: string }>;
|
||||
@@ -16,6 +16,11 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
{error && (
|
||||
<div role="alert" className="mt-4 rounded bg-danger-bg px-3 py-2 text-sm text-danger-text">
|
||||
{error}
|
||||
<form action={logout} className="mt-2">
|
||||
<button type="submit" className="text-xs font-semibold underline hover:no-underline">
|
||||
Abmelden und mit anderem Konto versuchen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
24
app/api/cron/apply-pending-changes/route.ts
Normal file
24
app/api/cron/apply-pending-changes/route.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { createAdminClient } from "@/lib/supabase/admin";
|
||||
|
||||
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
|
||||
// ändern with a future "Wirksam ab" date) once their date has arrived — see
|
||||
// apply_due_pending_changes() in supabase/migrations. Runs as a Vercel Cron
|
||||
// job (see vercel.json), not on behalf of any HR user, so it authenticates
|
||||
// via a shared secret rather than a Supabase session and uses the
|
||||
// service-role client (the one legitimate server-only use case for it).
|
||||
export async function GET(request: NextRequest) {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { data, error } = await supabase.rpc("apply_due_pending_changes");
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ applied: data });
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { OrganisationTab } from "./tabs/OrganisationTab";
|
||||
import { StammdatenTab } from "./tabs/StammdatenTab";
|
||||
import { VertragTab } from "./tabs/VertragTab";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
@@ -36,14 +36,13 @@ type EmployeeDetailProps = {
|
||||
teams: Team[];
|
||||
locations: Location[];
|
||||
openPositions: OpenPosition[];
|
||||
canEdit: boolean;
|
||||
};
|
||||
|
||||
type PanelType = "transfer" | "promote" | "karenz" | "daten" | "terminate" | "rehire" | null;
|
||||
const TABS = ["Stammdaten", "Vertrag & Gehalt", "Organisation", "Historie"] as const;
|
||||
const TABS = ["Stammdaten", "Vertrag", "Organisation", "Historie"] as const;
|
||||
|
||||
export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
const { employee, manager, directReports, history, divisions, departments, teams, locations, canEdit } = props;
|
||||
const { employee, manager, directReports, history, divisions, departments, teams, locations } = props;
|
||||
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
|
||||
const [panel, setPanel] = useState<PanelType>(null);
|
||||
|
||||
@@ -77,32 +76,30 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isActive && (
|
||||
<>
|
||||
<ActionButton icon={ArrowRightLeft} label="Versetzen" onClick={() => setPanel("transfer")} />
|
||||
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
|
||||
<ActionButton icon={Clock} label={employee.status === "Karenz" ? "Karenz verwalten" : "Karenz"} onClick={() => setPanel("karenz")} />
|
||||
<ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />
|
||||
<button
|
||||
onClick={() => setPanel("terminate")}
|
||||
className="flex items-center gap-1.5 rounded border border-danger-solid px-3 py-1.5 text-sm font-semibold text-danger-solid hover:bg-danger-bg"
|
||||
>
|
||||
<XCircle className="h-4 w-4" /> Austritt
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{employee.status === "Ausgetreten" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isActive && (
|
||||
<>
|
||||
<ActionButton icon={ArrowRightLeft} label="Versetzen" onClick={() => setPanel("transfer")} />
|
||||
<ActionButton icon={TrendingUp} label="Befördern" onClick={() => setPanel("promote")} />
|
||||
<ActionButton icon={Clock} label={employee.status === "Karenz" ? "Karenz verwalten" : "Karenz"} onClick={() => setPanel("karenz")} />
|
||||
<ActionButton icon={Pencil} label="Daten ändern" onClick={() => setPanel("daten")} />
|
||||
<button
|
||||
onClick={() => setPanel("rehire")}
|
||||
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"
|
||||
onClick={() => setPanel("terminate")}
|
||||
className="flex items-center gap-1.5 rounded border border-danger-solid px-3 py-1.5 text-sm font-semibold text-danger-solid hover:bg-danger-bg"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
|
||||
<XCircle className="h-4 w-4" /> Austritt
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{employee.status === "Ausgetreten" && (
|
||||
<button
|
||||
onClick={() => setPanel("rehire")}
|
||||
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"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" /> Wiedereinstellen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -122,29 +119,25 @@ export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||
|
||||
<div className="rounded border border-border bg-white p-6">
|
||||
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} />}
|
||||
{tab === "Vertrag & Gehalt" && <VertragTab employee={employee} />}
|
||||
{tab === "Vertrag" && <VertragTab employee={employee} />}
|
||||
{tab === "Organisation" && <OrganisationTab manager={manager} directReports={directReports} breadcrumb={breadcrumb} />}
|
||||
{tab === "Historie" && <HistorieTab history={history} />}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<>
|
||||
<TransferPanel
|
||||
open={panel === "transfer"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
divisions={divisions}
|
||||
departments={departments}
|
||||
teams={teams}
|
||||
currentTeamId={employee.team_id}
|
||||
/>
|
||||
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<DatenAendernPanel open={panel === "daten"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
|
||||
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
|
||||
</>
|
||||
)}
|
||||
<TransferPanel
|
||||
open={panel === "transfer"}
|
||||
onClose={() => setPanel(null)}
|
||||
employee={employee}
|
||||
divisions={divisions}
|
||||
departments={departments}
|
||||
teams={teams}
|
||||
currentTeamId={employee.team_id}
|
||||
/>
|
||||
<PromotePanel open={panel === "promote"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<KarenzPanel open={panel === "karenz"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<DatenAendernPanel open={panel === "daten"} onClose={() => setPanel(null)} employee={employee} />
|
||||
<TerminatePanel open={panel === "terminate"} onClose={() => setPanel(null)} employee={employee} directReportCount={directReports.length} />
|
||||
<RehirePanel open={panel === "rehire"} onClose={() => setPanel(null)} employee={employee} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useToast } from "@/components/ui/Toast";
|
||||
import { UN_COUNTRIES } from "@/lib/countries";
|
||||
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
export function DatenAendernPanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||
const { showToast } = useToast();
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Mode = "adjust" | "return";
|
||||
type EmploymentMode = "unverändert" | "Vollzeit" | "Teilzeit";
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Database, PaygradeType } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const PAYGRADES: { value: PaygradeType; label: string }[] = [
|
||||
{ value: "A", label: "A – Einstieg" },
|
||||
@@ -23,12 +23,11 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
|
||||
const router = useRouter();
|
||||
const [effectiveDate, setEffectiveDate] = useState("");
|
||||
const [newTitle, setNewTitle] = useState(employee.job_title);
|
||||
const [newSalary, setNewSalary] = useState(String(employee.monthly_salary_gross ?? ""));
|
||||
const [paygrade, setPaygrade] = useState<PaygradeType>(employee.paygrade);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!effectiveDate || !newTitle || !newSalary) {
|
||||
if (!effectiveDate || !newTitle) {
|
||||
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||
return;
|
||||
}
|
||||
@@ -37,7 +36,6 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
|
||||
employee_id: employee.id,
|
||||
effective_date: effectiveDate,
|
||||
new_title: newTitle,
|
||||
new_salary: Number(newSalary),
|
||||
new_paygrade: paygrade,
|
||||
});
|
||||
setPending(false);
|
||||
@@ -85,15 +83,6 @@ export function PromotePanel({ open, onClose, employee }: { open: boolean; onClo
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Neue Position*</label>
|
||||
<input value={newTitle} onChange={(e) => setNewTitle(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Neues Bruttogehalt (14x/Jahr)*</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newSalary}
|
||||
onChange={(e) => setNewSalary(e.target.value)}
|
||||
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Paygrade</label>
|
||||
<select
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useToast } from "@/components/ui/Toast";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
export function RehirePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||
const { showToast } = useToast();
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const EXIT_REASONS = ["Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung"];
|
||||
const CHECKLIST_ITEMS = ["IT-Zugänge deaktivieren", "Hardware retournieren", "ÖGK-Abmeldung", "Endabrechnung & Dienstzeugnis"];
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SlideOver } from "@/components/ui/SlideOver";
|
||||
import { useToast } from "@/components/ui/Toast";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fmtAge, fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||
|
||||
export function StammdatenTab({ employee, location }: { employee: EmployeeRow; location?: Location }) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fmtDate, fmtEUR } from "@/lib/format";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||
type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"];
|
||||
|
||||
const PAYGRADE_LABELS: Record<string, string> = {
|
||||
A: "A – Einstieg",
|
||||
@@ -21,7 +21,6 @@ export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||
["Wochenstunden", `${employee.weekly_hours} h`],
|
||||
["Urlaubsanspruch", "25 Tage"],
|
||||
["Paygrade", PAYGRADE_LABELS[employee.paygrade] ?? employee.paygrade],
|
||||
["Bruttogehalt (14x/Jahr)", fmtEUR(employee.monthly_salary_gross)],
|
||||
];
|
||||
if (employee.exit_date) rows.push(["Austrittsdatum", fmtDate(employee.exit_date)]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { hireEmployee } from "@/actions/employees";
|
||||
import { deleteHireDraft, saveHireDraft } from "@/actions/hireDrafts";
|
||||
@@ -27,24 +27,18 @@ type HireWizardProps = {
|
||||
export function HireWizard({ open, onClose, openPositions, locations, resumeDraft, initialPositionId }: HireWizardProps) {
|
||||
const { showToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState(0);
|
||||
const [draft, setDraft] = useState<HireDraftData>(EMPTY_HIRE_DRAFT);
|
||||
const [draftId, setDraftId] = useState<string | undefined>(undefined);
|
||||
// The parent remounts this component (via a changing `key`) each time it's
|
||||
// freshly opened, so these initializers — reading resumeDraft/
|
||||
// initialPositionId once at mount — are the reset, no effect needed.
|
||||
const [step, setStep] = useState(() => resumeDraft?.step ?? 0);
|
||||
const [draft, setDraft] = useState<HireDraftData>(() =>
|
||||
resumeDraft
|
||||
? { ...EMPTY_HIRE_DRAFT, ...(resumeDraft.payload as Partial<HireDraftData>) }
|
||||
: { ...EMPTY_HIRE_DRAFT, positionId: initialPositionId ?? "" }
|
||||
);
|
||||
const [draftId] = useState<string | undefined>(() => resumeDraft?.id);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (resumeDraft) {
|
||||
setDraft({ ...EMPTY_HIRE_DRAFT, ...(resumeDraft.payload as Partial<HireDraftData>) });
|
||||
setStep(resumeDraft.step);
|
||||
setDraftId(resumeDraft.id);
|
||||
} else {
|
||||
setDraft({ ...EMPTY_HIRE_DRAFT, positionId: initialPositionId ?? "" });
|
||||
setStep(0);
|
||||
setDraftId(undefined);
|
||||
}
|
||||
}, [open, resumeDraft, initialPositionId]);
|
||||
|
||||
const selectedPosition = useMemo(
|
||||
() => openPositions.find((p) => p.id === draft.positionId) ?? null,
|
||||
[openPositions, draft.positionId]
|
||||
@@ -57,7 +51,7 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
const stepValid = [
|
||||
Boolean(draft.firstName && draft.lastName && draft.birthDate && draft.locationId),
|
||||
Boolean(draft.positionId && draft.besetzung),
|
||||
Boolean(draft.entryDate && draft.salary),
|
||||
Boolean(draft.entryDate),
|
||||
true,
|
||||
][step];
|
||||
|
||||
@@ -89,7 +83,6 @@ export function HireWizard({ open, onClose, openPositions, locations, resumeDraf
|
||||
contract_end_date: draft.contractType === "befristet" ? draft.contractEndDate : undefined,
|
||||
employment_type: draft.employmentType,
|
||||
weekly_hours: Number(draft.weeklyHours),
|
||||
monthly_salary_gross: Number(draft.salary),
|
||||
paygrade: draft.paygrade,
|
||||
source: draft.besetzung,
|
||||
});
|
||||
|
||||
@@ -29,17 +29,24 @@ export function HireWizardProvider({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [resumeDraft, setResumeDraft] = useState<HireDraft | null>(null);
|
||||
const [initialPositionId, setInitialPositionId] = useState<string | undefined>(undefined);
|
||||
// Forces HireWizard to remount fresh each time it's opened, so its
|
||||
// internal draft/step state is (re-)initialized directly from the current
|
||||
// resumeDraft/initialPositionId props at mount time — no reset-on-open
|
||||
// effect needed inside HireWizard itself.
|
||||
const [openKey, setOpenKey] = useState(0);
|
||||
|
||||
function openWizard(options?: OpenWizardOptions) {
|
||||
setResumeDraft(options?.draftId ? (drafts.find((d) => d.id === options.draftId) ?? null) : null);
|
||||
setInitialPositionId(options?.positionId);
|
||||
setOpen(true);
|
||||
setOpenKey((k) => k + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<HireWizardContext.Provider value={{ openWizard }}>
|
||||
{children}
|
||||
<HireWizard
|
||||
key={openKey}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
openPositions={openPositions}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fmtDate, fmtEUR } from "@/lib/format";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import type { OpenPositionResolved } from "@/lib/positions";
|
||||
import type { HireDraftData } from "./types";
|
||||
|
||||
@@ -34,7 +34,6 @@ export function StepSummary({ draft, selectedPosition, locations }: StepSummaryP
|
||||
["Eintrittsdatum", fmtDate(draft.entryDate)],
|
||||
["Vertragsart", draft.contractType === "befristet" ? `befristet bis ${fmtDate(draft.contractEndDate)}` : "unbefristet"],
|
||||
["Beschäftigungsausmaß", `${draft.employmentType} (${draft.weeklyHours} h)`],
|
||||
["Bruttogehalt (14x/Jahr)", fmtEUR(Number(draft.salary))],
|
||||
["Paygrade", PAYGRADE_LABELS[draft.paygrade]],
|
||||
];
|
||||
|
||||
|
||||
@@ -72,10 +72,6 @@ export function StepVertrag({ draft, update }: { draft: HireDraftData; update: (
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Bruttogehalt/Monat (14x)*</label>
|
||||
<input type="number" value={draft.salary} onChange={(e) => update({ salary: e.target.value })} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-ink">Paygrade*</label>
|
||||
<select
|
||||
|
||||
@@ -19,7 +19,6 @@ export type HireDraftData = {
|
||||
contractEndDate: string;
|
||||
employmentType: EmploymentType;
|
||||
weeklyHours: string;
|
||||
salary: string;
|
||||
paygrade: PaygradeType;
|
||||
};
|
||||
|
||||
@@ -39,6 +38,5 @@ export const EMPTY_HIRE_DRAFT: HireDraftData = {
|
||||
contractEndDate: "",
|
||||
employmentType: "Vollzeit",
|
||||
weeklyHours: "38.5",
|
||||
salary: "",
|
||||
paygrade: "B",
|
||||
};
|
||||
|
||||
@@ -39,7 +39,6 @@ type ReportsPageClientProps = {
|
||||
function formatValue(measure: Measure, value: number): string {
|
||||
if (["headcount", "hires", "exits"].includes(measure)) return String(Math.round(value));
|
||||
if (measure === "fte") return value.toFixed(1);
|
||||
if (measure === "avg_salary") return new Intl.NumberFormat("de-AT", { style: "currency", currency: "EUR" }).format(value);
|
||||
if (measure === "parttime_rate" || measure === "female_share") return `${value.toFixed(1)}%`;
|
||||
if (measure === "avg_age" || measure === "avg_tenure") return `${value.toFixed(1)} Jahre`;
|
||||
return value.toFixed(1);
|
||||
|
||||
@@ -22,21 +22,18 @@ function titleFor(pathname: string): string {
|
||||
|
||||
type TopbarProps = {
|
||||
userLabel: string;
|
||||
role?: string;
|
||||
canEdit: boolean;
|
||||
};
|
||||
|
||||
export function Topbar({ userLabel, role, canEdit }: TopbarProps) {
|
||||
export function Topbar({ userLabel }: 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">
|
||||
{canEdit && <NewHireButton />}
|
||||
<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" />
|
||||
|
||||
@@ -14,10 +14,18 @@ type CountryPickerProps = {
|
||||
// value, not an object with its own detail fields.
|
||||
export function CountryPicker({ value, onChange, countries, placeholder = "Land suchen…" }: CountryPickerProps) {
|
||||
const [query, setQuery] = useState(value);
|
||||
const [prevValue, setPrevValue] = useState(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => setQuery(value), [value]);
|
||||
// Re-sync the local draft text when `value` changes externally (e.g. the
|
||||
// surrounding form loads a different employee). Adjusting state directly
|
||||
// during render — rather than in an effect — avoids an extra render pass
|
||||
// and the synchronous setState-in-effect this used to do.
|
||||
if (value !== prevValue) {
|
||||
setPrevValue(value);
|
||||
setQuery(value);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
|
||||
@@ -18,30 +18,38 @@ export function Lookup<T>({ placeholder = "Suchen…", onSearch, renderResult, o
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<T[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Derived from "have we finished searching for the current query yet",
|
||||
// rather than a separate state flag flipped synchronously at the top of
|
||||
// the effect below — the effect only ever sets state from the async
|
||||
// search's own completion callback now.
|
||||
const [lastSearchedQuery, setLastSearchedQuery] = useState<string | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const tooShort = query.trim().length < minChars;
|
||||
const loading = !tooShort && lastSearchedQuery !== query.trim();
|
||||
|
||||
useEffect(() => {
|
||||
if (query.trim().length < minChars) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (tooShort) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
const timeout = setTimeout(() => {
|
||||
onSearch(query.trim()).then((res) => {
|
||||
if (cancelled) return;
|
||||
setResults(res);
|
||||
setOpen(true);
|
||||
setLoading(false);
|
||||
setLastSearchedQuery(query.trim());
|
||||
});
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [query, minChars, onSearch]);
|
||||
}, [query, minChars, onSearch, tooShort]);
|
||||
|
||||
// Derived rather than reset via effect: once the query drops below
|
||||
// minChars, hide the dropdown and any stale results immediately without
|
||||
// needing a synchronous setState inside the effect above.
|
||||
const showDropdown = open && !tooShort;
|
||||
const visibleResults = tooShort ? [] : results;
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
@@ -77,12 +85,12 @@ export function Lookup<T>({ placeholder = "Suchen…", onSearch, renderResult, o
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && (
|
||||
{showDropdown && (
|
||||
<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 && visibleResults.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
|
||||
{!loading &&
|
||||
results.map((item, i) => (
|
||||
visibleResults.map((item, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
|
||||
@@ -4,11 +4,6 @@ const dateFormatter = new Intl.DateTimeFormat("de-AT", {
|
||||
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;
|
||||
@@ -16,11 +11,6 @@ export function fmtDate(date: string | Date | null | undefined): string {
|
||||
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();
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function loadOpenPositions(supabase: SupabaseClient<Database>): Pro
|
||||
new Set((positions ?? []).map((p) => p.reports_to_employee_id).filter((id): id is string => Boolean(id)))
|
||||
);
|
||||
const { data: managers } = managerIds.length
|
||||
? await supabase.from("employees_directory").select("id, first_name, last_name").in("id", managerIds)
|
||||
? await supabase.from("employees").select("id, first_name, last_name").in("id", managerIds)
|
||||
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
||||
const managerNameById = new Map((managers ?? []).map((m) => [m.id, `${m.first_name} ${m.last_name}`]));
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ export type Measure =
|
||||
| "fte"
|
||||
| "hires"
|
||||
| "exits"
|
||||
| "avg_salary"
|
||||
| "parttime_rate"
|
||||
| "avg_age"
|
||||
| "avg_tenure"
|
||||
@@ -26,7 +25,6 @@ export const MEASURE_LABELS: Record<Measure, string> = {
|
||||
fte: "FTE",
|
||||
hires: "Eintritte",
|
||||
exits: "Austritte",
|
||||
avg_salary: "Ø Bruttogehalt",
|
||||
parttime_rate: "Teilzeitquote",
|
||||
avg_age: "Ø Alter",
|
||||
avg_tenure: "Ø Zugehörigkeit",
|
||||
@@ -46,7 +44,7 @@ export const GROUP_LABELS: Record<GroupDimension, string> = {
|
||||
paygrade: "Paygrade",
|
||||
};
|
||||
|
||||
export const AVERAGE_MEASURES: Measure[] = ["avg_salary", "parttime_rate", "avg_age", "avg_tenure", "female_share"];
|
||||
export const AVERAGE_MEASURES: Measure[] = ["parttime_rate", "avg_age", "avg_tenure", "female_share"];
|
||||
export const DATE_SCOPED_MEASURES: Measure[] = ["hires", "exits"];
|
||||
|
||||
export type ReportEmployee = {
|
||||
@@ -63,7 +61,6 @@ export type ReportEmployee = {
|
||||
entry_date: string;
|
||||
exit_date: string | null;
|
||||
weekly_hours: number;
|
||||
monthly_salary_gross: number | null;
|
||||
source: string;
|
||||
paygrade: string;
|
||||
birth_date: string;
|
||||
@@ -127,10 +124,6 @@ export function measureValue(rows: ReportEmployee[], measure: Measure): number {
|
||||
return rows.length;
|
||||
case "fte":
|
||||
return rows.reduce((s, e) => s + e.weekly_hours / 38.5, 0);
|
||||
case "avg_salary": {
|
||||
const withSalary = rows.filter((e) => e.monthly_salary_gross != null);
|
||||
return withSalary.length ? withSalary.reduce((s, e) => s + (e.monthly_salary_gross ?? 0), 0) / withSalary.length : 0;
|
||||
}
|
||||
case "parttime_rate":
|
||||
return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100;
|
||||
case "avg_age":
|
||||
@@ -194,7 +187,7 @@ export function aggregateReport(
|
||||
export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimension; split?: GroupDimension }[] = [
|
||||
{ name: "Headcount nach Bereich", measure: "headcount", group: "division" },
|
||||
{ name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" },
|
||||
{ name: "Ø Gehalt nach Paygrade", measure: "avg_salary", group: "paygrade" },
|
||||
{ name: "Headcount nach Paygrade", measure: "headcount", group: "paygrade" },
|
||||
{ name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" },
|
||||
{ name: "Eintritte nach Bereich", measure: "hires", group: "division" },
|
||||
{ name: "Austritte nach Abteilung", measure: "exits", group: "department" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 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).
|
||||
// Hand-written to match supabase/schema.sql + supabase/migrations/*.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";
|
||||
@@ -8,7 +9,10 @@ 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";
|
||||
// Single HR-only role (see docs/decisions/0001-hr-only-access.md). Kept as a
|
||||
// union (not a string literal) so a future hr_admin/hr_user split, if ever
|
||||
// technically required, is a type-level addition, not a rewrite.
|
||||
export type ProfileRole = "hr";
|
||||
export type HistoryEventType =
|
||||
| "Eintritt"
|
||||
| "Beförderung"
|
||||
@@ -23,6 +27,14 @@ export type HistoryEventType =
|
||||
| "Rückkehr";
|
||||
export type PositionStatus = "open" | "filled";
|
||||
export type ReorgMoveKind = "emp" | "team" | "abt" | "dept";
|
||||
export type PendingChangeType =
|
||||
| "transfer"
|
||||
| "promotion"
|
||||
| "karenz_start"
|
||||
| "karenz_return"
|
||||
| "contract_change"
|
||||
| "reorg";
|
||||
export type PendingChangeStatus = "pending" | "applied" | "cancelled";
|
||||
|
||||
// @supabase/postgrest-js requires every table/view to carry a Relationships
|
||||
// array (used for typed embedded selects) — left empty since no code in this
|
||||
@@ -53,9 +65,36 @@ export type Database = {
|
||||
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 }>;
|
||||
Row: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
role: ProfileRole;
|
||||
is_active: boolean;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name?: string | null;
|
||||
role?: ProfileRole;
|
||||
is_active?: boolean;
|
||||
created_by?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: Partial<{
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string | null;
|
||||
role: ProfileRole;
|
||||
is_active: boolean;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
};
|
||||
employees: NoRelationships & {
|
||||
Row: {
|
||||
@@ -80,7 +119,8 @@ export type Database = {
|
||||
is_lead: boolean;
|
||||
employment_type: EmploymentType;
|
||||
weekly_hours: number;
|
||||
monthly_salary_gross: number;
|
||||
/** @deprecated Salary is out of MVP scope; column kept only for pre-existing data. */
|
||||
monthly_salary_gross: number | null;
|
||||
contract_type: ContractType;
|
||||
contract_end_date: string | null;
|
||||
paygrade: PaygradeType;
|
||||
@@ -89,6 +129,7 @@ export type Database = {
|
||||
entry_date: string;
|
||||
exit_date: string | null;
|
||||
exit_reason: string | null;
|
||||
karenz_start_date: string | null;
|
||||
karenz_return_date: string | null;
|
||||
avatar_color: string | null;
|
||||
created_at: string;
|
||||
@@ -115,7 +156,6 @@ export type Database = {
|
||||
is_lead?: boolean;
|
||||
employment_type?: EmploymentType;
|
||||
weekly_hours?: number;
|
||||
monthly_salary_gross: number;
|
||||
contract_type?: ContractType;
|
||||
contract_end_date?: string | null;
|
||||
paygrade?: PaygradeType;
|
||||
@@ -124,6 +164,7 @@ export type Database = {
|
||||
entry_date: string;
|
||||
exit_date?: string | null;
|
||||
exit_reason?: string | null;
|
||||
karenz_start_date?: string | null;
|
||||
karenz_return_date?: string | null;
|
||||
avatar_color?: string | null;
|
||||
created_at?: string;
|
||||
@@ -138,6 +179,7 @@ export type Database = {
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
description: string;
|
||||
reorg_scenario_id: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
@@ -146,6 +188,7 @@ export type Database = {
|
||||
event_date: string;
|
||||
event_type: HistoryEventType;
|
||||
description: string;
|
||||
reorg_scenario_id?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["employee_history"]["Insert"]>;
|
||||
@@ -240,47 +283,35 @@ export type Database = {
|
||||
Insert: { id?: string; scenario_id: string; kind: ReorgMoveKind; payload: Record<string, unknown> };
|
||||
Update: Partial<Database["public"]["Tables"]["reorg_moves"]["Insert"]>;
|
||||
};
|
||||
};
|
||||
Views: {
|
||||
employees_directory: NoRelationships & {
|
||||
pending_org_changes: 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;
|
||||
employee_id: string;
|
||||
change_type: PendingChangeType;
|
||||
effective_date: string;
|
||||
payload: Record<string, unknown>;
|
||||
reorg_scenario_id: string | null;
|
||||
status: PendingChangeStatus;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
applied_at: string | null;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
employee_id: string;
|
||||
change_type: PendingChangeType;
|
||||
effective_date: string;
|
||||
payload: Record<string, unknown>;
|
||||
reorg_scenario_id?: string | null;
|
||||
status?: PendingChangeStatus;
|
||||
created_by?: string | null;
|
||||
created_at?: string;
|
||||
applied_at?: string | null;
|
||||
};
|
||||
Update: Partial<Database["public"]["Tables"]["pending_org_changes"]["Insert"]>;
|
||||
};
|
||||
};
|
||||
Views: Record<string, never>;
|
||||
Functions: {
|
||||
hire_employee: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
terminate_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
@@ -295,6 +326,7 @@ export type Database = {
|
||||
staff_position_internally: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
apply_reorg: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||
undo_reorg: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||
apply_due_pending_changes: { Args: Record<string, never>; Returns: number };
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
1225
package-lock.json
generated
1225
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -6,7 +6,13 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:integration": "node --env-file=.env.test.local node_modules/vitest/vitest.mjs run --config vitest.integration.config.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"check": "npm run lint && npm run typecheck && npm run test && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.12.1",
|
||||
@@ -21,10 +27,13 @@
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "16.2.10",
|
||||
"postcss": "^8.5.19",
|
||||
"supabase": "^2.109.1",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
29
proxy.ts
29
proxy.ts
@@ -2,9 +2,16 @@ 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.
|
||||
// This is the app's single entry-point gate (spec
|
||||
// §2.2): unauthenticated users are sent to /login, and — this is the part
|
||||
// that used to be missing — authenticated users who are NOT an active,
|
||||
// explicitly-provisioned HR user are sent to /login too, with an error
|
||||
// message, instead of being let through. Previously this only checked for
|
||||
// a Supabase Auth session at all, which meant any signed-in user (even one
|
||||
// with no profile row, or the old "manager" role) could open the app.
|
||||
// This UI-layer gate is defense in depth, not the real boundary — every
|
||||
// table is independently RLS-gated on is_hr_user() regardless of what this
|
||||
// proxy does.
|
||||
export async function proxy(request: NextRequest) {
|
||||
let response = NextResponse.next({ request });
|
||||
|
||||
@@ -31,13 +38,25 @@ export async function proxy(request: NextRequest) {
|
||||
|
||||
const isLoginRoute = request.nextUrl.pathname.startsWith("/login");
|
||||
|
||||
if (!user && !isLoginRoute) {
|
||||
if (!user) {
|
||||
if (isLoginRoute) return response;
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
if (user && isLoginRoute) {
|
||||
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
|
||||
const isActiveHr = profile?.role === "hr" && profile?.is_active === true;
|
||||
|
||||
if (!isActiveHr) {
|
||||
if (isLoginRoute) return response;
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
url.searchParams.set("error", "Kein HR-Zugriff. Bitte wenden Sie sich an eine:n bestehende:n HR-Benutzer:in.");
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
if (isLoginRoute) {
|
||||
const url = request.nextUrl.clone();
|
||||
url.pathname = "/";
|
||||
return NextResponse.redirect(url);
|
||||
|
||||
33
supabase/config.toml
Normal file
33
supabase/config.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
project_id = "alpenwerk-hr"
|
||||
|
||||
[api]
|
||||
enabled = true
|
||||
port = 55321
|
||||
schemas = ["public", "graphql_public"]
|
||||
extra_search_path = ["public"]
|
||||
max_rows = 1000
|
||||
|
||||
[db]
|
||||
port = 55322
|
||||
major_version = 15
|
||||
|
||||
[studio]
|
||||
enabled = true
|
||||
port = 55323
|
||||
|
||||
[inbucket]
|
||||
enabled = true
|
||||
port = 55324
|
||||
|
||||
[analytics]
|
||||
enabled = false
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
site_url = "http://127.0.0.1:3000"
|
||||
additional_redirect_urls = ["http://127.0.0.1:3000"]
|
||||
jwt_expiry = 3600
|
||||
enable_signup = true
|
||||
|
||||
[auth.email]
|
||||
enable_confirmations = false
|
||||
360
supabase/migrations/20260601000000_initial_schema.sql
Normal file
360
supabase/migrations/20260601000000_initial_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());
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Addendum to supabase/schema.sql — run after that file.
|
||||
--
|
||||
-- Staatsbürgerschaft and Wohnland now use a searchable picker over the
|
||||
-- full UN member states list (193 countries, see lib/countries.ts)
|
||||
-- instead of the original ~9/5-value picklists. The old CHECK constraints
|
||||
-- would reject nearly all of those values, so they're dropped here. The
|
||||
-- app is the source of truth for valid values (same approach the rest of
|
||||
-- the app already relies on for large open-ended pickers); the columns
|
||||
-- stay plain text (nationality keeps its NOT NULL).
|
||||
alter table employees drop constraint if exists employees_nationality_check;
|
||||
alter table employees drop constraint if exists employees_address_country_check;
|
||||
@@ -0,0 +1,555 @@
|
||||
-- Alpenwerk HR — mutation RPCs (Phase 2/3)
|
||||
--
|
||||
-- One Postgres function per business mutation from the spec
|
||||
-- §4.4/§4.5/§4.6/§4.7. Each function runs as SECURITY INVOKER (the caller's own
|
||||
-- session), so the existing RLS policy on `employees` (hr_admin only) is the
|
||||
-- real authorization gate; the is_hr_admin() check at the top of each function
|
||||
-- just produces a clearer error message than a bare RLS violation.
|
||||
--
|
||||
-- A single function call is one Postgres transaction: if any statement raises,
|
||||
-- everything in that call rolls back automatically. Run this whole file in the
|
||||
-- Supabase SQL Editor after supabase/schema.sql.
|
||||
|
||||
alter table employee_history add column if not exists reorg_scenario_id uuid references reorg_scenarios(id);
|
||||
|
||||
create or replace function current_actor_name()
|
||||
returns text language sql stable as $$
|
||||
select coalesce(p.full_name, p.email, 'Unbekannt') from profiles p where p.id = auth.uid();
|
||||
$$;
|
||||
|
||||
create or replace function require_hr_admin()
|
||||
returns void language plpgsql as $$
|
||||
begin
|
||||
if not is_hr_admin() then
|
||||
raise exception 'Nicht berechtigt: nur HR-Admin darf diese Aktion ausführen.';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- Manager derivation (§2's "reports-to" rule), used by every mutation that
|
||||
-- changes an employee's team/leadership status.
|
||||
create or replace function resolve_manager_for(p_team_id uuid, p_is_lead boolean, p_division_id uuid)
|
||||
returns uuid language plpgsql as $$
|
||||
declare
|
||||
v_manager uuid;
|
||||
begin
|
||||
if p_team_id is not null and not p_is_lead then
|
||||
select id into v_manager from employees
|
||||
where team_id = p_team_id and is_lead = true and status <> 'Ausgetreten' limit 1;
|
||||
elsif p_team_id is not null and p_is_lead then
|
||||
select id into v_manager from employees
|
||||
where division_id = p_division_id and team_id is null and org_level = 1 and status <> 'Ausgetreten' limit 1;
|
||||
else
|
||||
select id into v_manager from employees where org_level = 0 and status <> 'Ausgetreten' limit 1;
|
||||
end if;
|
||||
return v_manager;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function generate_company_email(p_first_name text, p_last_name text)
|
||||
returns text language plpgsql as $$
|
||||
declare
|
||||
base text;
|
||||
candidate text;
|
||||
n int := 1;
|
||||
translit text;
|
||||
begin
|
||||
translit := lower(p_first_name || '.' || p_last_name);
|
||||
translit := replace(replace(replace(replace(translit, 'ä','ae'), 'ö','oe'), 'ü','ue'), 'ß','ss');
|
||||
base := regexp_replace(translit, '[^a-z0-9.]', '', 'g');
|
||||
candidate := base || '@test.manner.at';
|
||||
while exists (select 1 from employees where email = candidate) loop
|
||||
n := n + 1;
|
||||
candidate := base || n::text || '@test.manner.at';
|
||||
end loop;
|
||||
return candidate;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Hire (§4.4) ───────────────────────────────────────────────
|
||||
create or replace function hire_employee(payload jsonb)
|
||||
returns uuid language plpgsql as $$
|
||||
declare
|
||||
v_id uuid;
|
||||
v_team_id uuid;
|
||||
v_division_id uuid;
|
||||
v_position record;
|
||||
v_job_title text;
|
||||
v_email text;
|
||||
v_manager uuid;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
if payload->>'position_id' is not null then
|
||||
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
|
||||
if not found then
|
||||
raise exception 'Position ist nicht mehr offen.';
|
||||
end if;
|
||||
v_team_id := v_position.team_id;
|
||||
v_division_id := v_position.division_id;
|
||||
v_job_title := coalesce(payload->>'job_title', v_position.title);
|
||||
else
|
||||
v_team_id := (payload->>'team_id')::uuid;
|
||||
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
|
||||
v_job_title := payload->>'job_title';
|
||||
end if;
|
||||
|
||||
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
|
||||
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
|
||||
|
||||
insert into employees (
|
||||
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
|
||||
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
|
||||
employment_type, weekly_hours, monthly_salary_gross, contract_type, contract_end_date,
|
||||
paygrade, source, status, entry_date
|
||||
) values (
|
||||
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
|
||||
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
|
||||
v_email, payload->>'phone',
|
||||
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
|
||||
v_manager, 3, false,
|
||||
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
|
||||
coalesce((payload->>'weekly_hours')::numeric, 38.5),
|
||||
(payload->>'monthly_salary_gross')::numeric,
|
||||
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
|
||||
nullif(payload->>'contract_end_date', '')::date,
|
||||
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
|
||||
coalesce((payload->>'source')::source_type, 'Extern'),
|
||||
(case when (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
|
||||
(payload->>'entry_date')::date
|
||||
) returning id into v_id;
|
||||
|
||||
if payload->>'position_id' is not null then
|
||||
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
|
||||
where id = (payload->>'position_id')::uuid;
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Austritt (§4.5) ───────────────────────────────────────────
|
||||
create or replace function terminate_employee(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_manager uuid;
|
||||
v_name text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select manager_id, first_name || ' ' || last_name into v_manager, v_name from employees where id = v_employee_id;
|
||||
|
||||
update employees set manager_id = v_manager where manager_id = v_employee_id and status <> 'Ausgetreten';
|
||||
|
||||
update employees set
|
||||
status = 'Ausgetreten',
|
||||
exit_date = (payload->>'exit_date')::date,
|
||||
exit_reason = payload->>'exit_reason'
|
||||
where id = v_employee_id;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, (payload->>'exit_date')::date, 'Austritt',
|
||||
'Austritt (' || (payload->>'exit_reason') || ')' || case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Austritt', v_name, v_employee_id, payload->>'exit_reason');
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Versetzung (§4.5) ─────────────────────────────────────────
|
||||
create or replace function transfer_employee(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_new_team_id uuid := (payload->>'new_team_id')::uuid;
|
||||
v_division_id uuid;
|
||||
v_manager uuid;
|
||||
v_name text;
|
||||
v_is_lead boolean;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_new_team_id;
|
||||
select is_lead, first_name || ' ' || last_name into v_is_lead, v_name from employees where id = v_employee_id;
|
||||
v_manager := resolve_manager_for(v_new_team_id, v_is_lead, v_division_id);
|
||||
|
||||
update employees set
|
||||
team_id = v_new_team_id,
|
||||
job_title = coalesce(nullif(payload->>'new_title', ''), job_title),
|
||||
manager_id = v_manager
|
||||
where id = v_employee_id;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, (payload->>'effective_date')::date, 'Versetzung',
|
||||
'Versetzung, wirksam ab ' || (payload->>'effective_date') ||
|
||||
case when payload->>'new_title' is not null and payload->>'new_title' <> '' then ', neue Position: ' || (payload->>'new_title') else '' end);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || (payload->>'effective_date'));
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Beförderung (§4.5) ────────────────────────────────────────
|
||||
create or replace function promote_employee(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_old_paygrade paygrade_type;
|
||||
v_name text;
|
||||
v_details text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select paygrade, first_name || ' ' || last_name into v_old_paygrade, v_name from employees where id = v_employee_id;
|
||||
|
||||
update employees set
|
||||
job_title = payload->>'new_title',
|
||||
monthly_salary_gross = (payload->>'new_salary')::numeric,
|
||||
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
|
||||
where id = v_employee_id;
|
||||
|
||||
v_details := 'Neue Position: ' || (payload->>'new_title');
|
||||
if payload->>'new_paygrade' is not null and (payload->>'new_paygrade')::paygrade_type <> v_old_paygrade then
|
||||
v_details := v_details || ', neue Paygrade: ' || (payload->>'new_paygrade');
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, (payload->>'effective_date')::date, 'Beförderung', v_details);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details);
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Karenz verwalten (§4.5) — adjust return date or record actual return ──
|
||||
create or replace function adjust_karenz_return(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_name text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
||||
|
||||
update employees set karenz_return_date = (payload->>'new_return_date')::date where id = v_employee_id;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, current_date, 'Karenz',
|
||||
'Rückkehrdatum angepasst auf ' || (payload->>'new_return_date') ||
|
||||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Neues Rückkehrdatum: ' || (payload->>'new_return_date'));
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function record_karenz_return(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_return_date date := (payload->>'return_date')::date;
|
||||
v_name text;
|
||||
v_team_id uuid;
|
||||
v_division_id uuid;
|
||||
v_is_lead boolean;
|
||||
v_manager uuid;
|
||||
v_employment_type employment_type;
|
||||
v_weekly_hours numeric;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name, team_id, division_id, is_lead
|
||||
into v_name, v_team_id, v_division_id, v_is_lead
|
||||
from employees where id = v_employee_id;
|
||||
|
||||
if payload->>'employment_mode' = 'Vollzeit' then
|
||||
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
|
||||
elsif payload->>'employment_mode' = 'Teilzeit' then
|
||||
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
|
||||
end if;
|
||||
|
||||
if v_return_date <= current_date then
|
||||
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
|
||||
update employees set
|
||||
status = 'Aktiv',
|
||||
karenz_return_date = null,
|
||||
manager_id = v_manager,
|
||||
employment_type = coalesce(v_employment_type, employment_type),
|
||||
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
|
||||
where id = v_employee_id;
|
||||
else
|
||||
update employees set karenz_return_date = v_return_date,
|
||||
employment_type = coalesce(v_employment_type, employment_type),
|
||||
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
|
||||
where id = v_employee_id;
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_return_date, 'Rückkehr', 'Wiedereintritt aus Karenz am ' || (payload->>'return_date'));
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || (payload->>'return_date'));
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Daten ändern (§4.5) — diffs person vs. contract fields ────
|
||||
create or replace function change_employee_data(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||
v_old employees%rowtype;
|
||||
v_name text;
|
||||
v_person_changes text[] := '{}';
|
||||
v_contract_changes text[] := '{}';
|
||||
v_person jsonb := payload->'person';
|
||||
v_contract jsonb := payload->'contract';
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_old from employees where id = v_employee_id;
|
||||
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||
|
||||
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
|
||||
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
|
||||
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
|
||||
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
|
||||
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
|
||||
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
|
||||
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
|
||||
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
|
||||
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
|
||||
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
|
||||
|
||||
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
|
||||
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
|
||||
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
|
||||
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
|
||||
|
||||
update employees set
|
||||
first_name = coalesce(v_person->>'first_name', first_name),
|
||||
last_name = coalesce(v_person->>'last_name', last_name),
|
||||
gender = coalesce((v_person->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
|
||||
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
|
||||
nationality = coalesce(v_person->>'nationality', nationality),
|
||||
address = coalesce(v_person->>'address', address),
|
||||
address_country = coalesce(v_person->>'address_country', address_country),
|
||||
email = coalesce(v_person->>'email', email),
|
||||
phone = coalesce(v_person->>'phone', phone),
|
||||
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end
|
||||
where id = v_employee_id;
|
||||
|
||||
if array_length(v_person_changes, 1) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
|
||||
end if;
|
||||
|
||||
if array_length(v_contract_changes, 1) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Wiedereinstellung (§4.5) ──────────────────────────────────
|
||||
create or replace function rehire_employee(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_rehire_date date := (payload->>'rehire_date')::date;
|
||||
v_team_id uuid;
|
||||
v_division_id uuid;
|
||||
v_is_lead boolean;
|
||||
v_manager uuid;
|
||||
v_name text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select team_id, division_id, is_lead, first_name || ' ' || last_name
|
||||
into v_team_id, v_division_id, v_is_lead, v_name
|
||||
from employees where id = v_employee_id;
|
||||
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
|
||||
|
||||
update employees set
|
||||
status = (case when v_rehire_date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
|
||||
entry_date = v_rehire_date,
|
||||
exit_date = null,
|
||||
exit_reason = null,
|
||||
manager_id = v_manager
|
||||
where id = v_employee_id;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_rehire_date, 'Wiedereintritt', 'Wiedereinstellung zum ' || (payload->>'rehire_date'));
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Wiedereinstellung', v_name, v_employee_id, 'Wiedereintritt am ' || (payload->>'rehire_date'));
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Position ausschreiben (§4.6) ──────────────────────────────
|
||||
create or replace function create_position(payload jsonb)
|
||||
returns uuid language plpgsql as $$
|
||||
declare
|
||||
v_id uuid;
|
||||
v_superior_id uuid := (payload->>'superior_employee_id')::uuid;
|
||||
v_is_lead boolean := coalesce((payload->>'is_lead')::boolean, false);
|
||||
v_team_id uuid;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
if v_is_lead then
|
||||
v_team_id := (payload->>'team_id')::uuid;
|
||||
else
|
||||
select team_id into v_team_id from employees where id = v_superior_id;
|
||||
end if;
|
||||
|
||||
insert into positions (title, team_id, is_lead, reports_to_employee_id)
|
||||
values (payload->>'title', v_team_id, v_is_lead, v_superior_id)
|
||||
returning id into v_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
||||
values (auth.uid(), current_actor_name(), 'Ausschreibung', payload->>'title', 'Position ausgeschrieben');
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Intern besetzen (§4.6) ────────────────────────────────────
|
||||
create or replace function staff_position_internally(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_position record;
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_manager uuid;
|
||||
v_name text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
|
||||
if not found then
|
||||
raise exception 'Position ist nicht mehr offen.';
|
||||
end if;
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
||||
|
||||
v_manager := resolve_manager_for(v_position.team_id, v_position.is_lead, v_position.division_id);
|
||||
|
||||
update employees set
|
||||
team_id = v_position.team_id,
|
||||
job_title = v_position.title,
|
||||
source = 'Intern',
|
||||
is_lead = case when v_position.is_lead then true else is_lead end,
|
||||
org_level = case when v_position.is_lead then 2 else org_level end,
|
||||
manager_id = v_manager
|
||||
where id = v_employee_id;
|
||||
|
||||
if v_position.is_lead then
|
||||
update employees set manager_id = v_employee_id
|
||||
where team_id = v_position.team_id and id <> v_employee_id and is_lead = false and status <> 'Ausgetreten';
|
||||
end if;
|
||||
|
||||
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_employee_id
|
||||
where id = v_position.id;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, current_date, 'Versetzung', 'Interne Besetzung: ' || v_position.title);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Interne Besetzung', v_name, v_employee_id, v_position.title);
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Reorganisation (§4.7) ─────────────────────────────────────
|
||||
-- payload: { name, effective_date, moves: [{ kind, label, employee_ids: uuid[], target_team_id }] }
|
||||
create or replace function apply_reorg(payload jsonb)
|
||||
returns uuid language plpgsql as $$
|
||||
declare
|
||||
v_scenario_id uuid;
|
||||
v_move jsonb;
|
||||
v_employee_id text;
|
||||
v_target_team_id uuid;
|
||||
v_division_id uuid;
|
||||
v_is_lead boolean;
|
||||
v_manager uuid;
|
||||
v_snapshot jsonb := '{}'::jsonb;
|
||||
v_old record;
|
||||
v_total_moves int := 0;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
insert into reorg_scenarios (name, effective_date, created_by, applied, applied_at)
|
||||
values (payload->>'name', (payload->>'effective_date')::date, auth.uid(), true, now())
|
||||
returning id into v_scenario_id;
|
||||
|
||||
for v_move in select * from jsonb_array_elements(payload->'moves')
|
||||
loop
|
||||
v_target_team_id := (v_move->>'target_team_id')::uuid;
|
||||
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_target_team_id;
|
||||
|
||||
insert into reorg_moves (scenario_id, kind, payload) values (v_scenario_id, v_move->>'kind', v_move);
|
||||
|
||||
for v_employee_id in select jsonb_array_elements_text(v_move->'employee_ids')
|
||||
loop
|
||||
select * into v_old from employees where id = v_employee_id::uuid;
|
||||
v_snapshot := v_snapshot || jsonb_build_object(v_employee_id, jsonb_build_object(
|
||||
'team_id', v_old.team_id, 'division_id', v_old.division_id, 'manager_id', v_old.manager_id
|
||||
));
|
||||
|
||||
v_is_lead := v_old.is_lead;
|
||||
v_manager := resolve_manager_for(v_target_team_id, v_is_lead, v_division_id);
|
||||
|
||||
update employees set team_id = v_target_team_id, manager_id = v_manager where id = v_employee_id::uuid;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id)
|
||||
values (v_employee_id::uuid, (payload->>'effective_date')::date, 'Reorganisation',
|
||||
'Reorganisation "' || (payload->>'name') || '": neues Team zugewiesen', v_scenario_id);
|
||||
|
||||
v_total_moves := v_total_moves + 1;
|
||||
end loop;
|
||||
end loop;
|
||||
|
||||
update reorg_scenarios set undo_snapshot = v_snapshot where id = v_scenario_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
||||
values (auth.uid(), current_actor_name(), 'Reorganisation', payload->>'name', v_total_moves || ' Mitarbeiter:innen betroffen');
|
||||
|
||||
return v_scenario_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function undo_reorg(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_scenario record;
|
||||
v_key text;
|
||||
v_val jsonb;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_scenario from reorg_scenarios where id = (payload->>'scenario_id')::uuid and applied = true;
|
||||
if not found or v_scenario.undo_snapshot is null then
|
||||
raise exception 'Reorganisation kann nicht rückgängig gemacht werden (kein Snapshot vorhanden).';
|
||||
end if;
|
||||
|
||||
for v_key, v_val in select * from jsonb_each(v_scenario.undo_snapshot)
|
||||
loop
|
||||
update employees set
|
||||
team_id = nullif(v_val->>'team_id','')::uuid,
|
||||
division_id = (v_val->>'division_id')::uuid,
|
||||
manager_id = nullif(v_val->>'manager_id','')::uuid
|
||||
where id = v_key::uuid;
|
||||
end loop;
|
||||
|
||||
delete from employee_history where reorg_scenario_id = v_scenario.id;
|
||||
update reorg_scenarios set applied = false where id = v_scenario.id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
||||
values (auth.uid(), current_actor_name(), 'Reorganisation rückgängig', v_scenario.name, 'Reorganisation zurückgesetzt');
|
||||
end;
|
||||
$$;
|
||||
29
supabase/migrations/20260601000300_start_karenz_function.sql
Normal file
29
supabase/migrations/20260601000300_start_karenz_function.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Addendum to supabase/functions.sql — run after that file.
|
||||
--
|
||||
-- The spec's "Karenz verwalten" panel (§4.5) only covers employees already on
|
||||
-- Karenz (adjust return date / record return). It doesn't specify the fields
|
||||
-- for *starting* a Karenz period from Aktiv, even though §4.3 clearly shows a
|
||||
-- "Karenz" button for that case. This fills that gap with a reasonable,
|
||||
-- minimal form: start date + planned return date + optional note.
|
||||
|
||||
create or replace function start_karenz(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_name text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
||||
|
||||
update employees set status = 'Karenz', karenz_return_date = (payload->>'planned_return_date')::date
|
||||
where id = v_employee_id;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, (payload->>'karenz_start_date')::date, 'Karenz',
|
||||
'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') ||
|
||||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date'));
|
||||
end;
|
||||
$$;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Addendum to supabase/schema.sql + functions.sql — run after those.
|
||||
--
|
||||
-- employee_history intentionally has no UPDATE/DELETE policy (§4.9's
|
||||
-- "unveraenderbar" / append-only requirement). But undo_reorg needs to
|
||||
-- remove the specific history rows a reorg created — found via live
|
||||
-- testing: the DELETE inside undo_reorg silently matched 0 rows under RLS
|
||||
-- (no error, since RLS just filters DELETE-eligible rows to none), leaving
|
||||
-- Reorganisation entries behind after an otherwise-successful undo.
|
||||
--
|
||||
-- Scope the exception as narrowly as possible: hr_admin may delete a
|
||||
-- history row only if it carries a reorg_scenario_id, i.e. only rows
|
||||
-- apply_reorg created. Eintritt/Austritt/Beförderung/etc. rows (always
|
||||
-- reorg_scenario_id IS NULL) remain fully immutable.
|
||||
create policy "history_delete_admin_reorg_undo" on employee_history for delete
|
||||
using (is_hr_admin() and reorg_scenario_id is not null);
|
||||
@@ -0,0 +1,71 @@
|
||||
-- Addendum to supabase/functions.sql — run after that file (and functions_2/3.sql).
|
||||
--
|
||||
-- "Daten ändern" had no "Wirksam ab" field, unlike Versetzung/Beförderung/
|
||||
-- Karenz — every change was silently logged with today's date regardless
|
||||
-- of when it should actually take effect. Adds an effective_date input
|
||||
-- (defaults to today if omitted) used for both the history event_date and
|
||||
-- noted in the change description.
|
||||
create or replace function change_employee_data(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||
v_old employees%rowtype;
|
||||
v_name text;
|
||||
v_person_changes text[] := '{}';
|
||||
v_contract_changes text[] := '{}';
|
||||
v_person jsonb := payload->'person';
|
||||
v_contract jsonb := payload->'contract';
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_old from employees where id = v_employee_id;
|
||||
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||
|
||||
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
|
||||
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
|
||||
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
|
||||
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
|
||||
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
|
||||
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
|
||||
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
|
||||
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
|
||||
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
|
||||
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
|
||||
|
||||
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
|
||||
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
|
||||
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
|
||||
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
|
||||
|
||||
update employees set
|
||||
first_name = coalesce(v_person->>'first_name', first_name),
|
||||
last_name = coalesce(v_person->>'last_name', last_name),
|
||||
gender = coalesce((v_person->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
|
||||
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
|
||||
nationality = coalesce(v_person->>'nationality', nationality),
|
||||
address = coalesce(v_person->>'address', address),
|
||||
address_country = coalesce(v_person->>'address_country', address_country),
|
||||
email = coalesce(v_person->>'email', email),
|
||||
phone = coalesce(v_person->>'phone', phone),
|
||||
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end
|
||||
where id = v_employee_id;
|
||||
|
||||
if array_length(v_person_changes, 1) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
|
||||
end if;
|
||||
|
||||
if array_length(v_contract_changes, 1) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
172
supabase/migrations/20260714120000_hr_only_access.sql
Normal file
172
supabase/migrations/20260714120000_hr_only_access.sql
Normal file
@@ -0,0 +1,172 @@
|
||||
-- HR-only access model (spec §2)
|
||||
--
|
||||
-- Rationale: the app previously had two profile roles (hr_admin / manager),
|
||||
-- with "manager" intended as a read-only, salary-masked role. The revised
|
||||
-- scope is HR-only: nobody except an active, explicitly-provisioned HR user
|
||||
-- may open the app at all. This migration:
|
||||
-- 1. Collapses profiles.role to the single allowed value 'hr'.
|
||||
-- 2. Adds profiles.is_active (default false — new users get zero access
|
||||
-- until an existing HR user explicitly activates them; see §2.3).
|
||||
-- 3. Renames the authorization gate from is_hr_admin() to is_hr_user()
|
||||
-- (checks role='hr' AND is_active=true) to match §2.4's naming and
|
||||
-- semantics, and repoints every RLS policy at it.
|
||||
-- 4. Fixes a real gap: several tables (divisions/departments/teams/
|
||||
-- locations, employee_history, positions, audit_log, reorg_scenarios/
|
||||
-- reorg_moves) had read policies scoped to `auth.role() = 'authenticated'`
|
||||
-- — i.e. ANY signed-in Supabase Auth user, not just HR. Every one of
|
||||
-- those is tightened to is_hr_user().
|
||||
-- 5. Drops the employees_directory salary-masking view: with the
|
||||
-- "manager" role gone and salary out of MVP scope (see the salary
|
||||
-- deprecation migration), there is nothing left to mask and no second
|
||||
-- role to mask it from. All reads now go through the base `employees`
|
||||
-- table, gated by the same is_hr_user()-only policy as writes.
|
||||
--
|
||||
-- The application layer (app/(app)/layout.tsx) previously let any
|
||||
-- authenticated Supabase user reach the shell (it only checked for a
|
||||
-- session, not profiles.role/is_active) and merely hid the edit UI for
|
||||
-- non-admins. That check is being replaced in the app code alongside this
|
||||
-- migration — this migration is what makes that enforceable at the data
|
||||
-- layer regardless of what the UI does.
|
||||
|
||||
-- ── profiles: single role, explicit activation ──────────────────────
|
||||
alter table profiles drop constraint if exists profiles_role_check;
|
||||
update profiles set role = 'hr' where role <> 'hr';
|
||||
alter table profiles add constraint profiles_role_check check (role = 'hr');
|
||||
alter table profiles alter column role set default 'hr';
|
||||
|
||||
alter table profiles add column if not exists is_active boolean not null default false;
|
||||
alter table profiles add column if not exists created_by uuid references auth.users(id);
|
||||
alter table profiles add column if not exists updated_at timestamptz not null default now();
|
||||
|
||||
-- Any *existing* profile row (i.e. someone already explicitly provisioned
|
||||
-- before this migration) keeps working — activation is only "off by
|
||||
-- default" for rows created from here on.
|
||||
update profiles set is_active = true where is_active = false;
|
||||
|
||||
create or replace function fn_touch_profiles_updated_at()
|
||||
returns trigger language plpgsql as $$
|
||||
begin
|
||||
new.updated_at = now();
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_profiles_touch_updated_at on profiles;
|
||||
create trigger trg_profiles_touch_updated_at
|
||||
before update on profiles
|
||||
for each row execute function fn_touch_profiles_updated_at();
|
||||
|
||||
-- ── Authorization gate: is_hr_user() replaces is_hr_admin() ─────────
|
||||
create or replace function is_hr_user()
|
||||
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' and p.is_active = true
|
||||
);
|
||||
$$;
|
||||
|
||||
create or replace function current_hr_user_id()
|
||||
returns uuid
|
||||
language sql
|
||||
security definer
|
||||
set search_path = public
|
||||
stable
|
||||
as $$
|
||||
select p.id from profiles p where p.id = auth.uid() and p.role = 'hr' and p.is_active = true;
|
||||
$$;
|
||||
|
||||
-- Back-compat shim so any not-yet-migrated call site (or a function defined
|
||||
-- in an older addendum file not touched by this migration) keeps working;
|
||||
-- new code should call is_hr_user() directly. Safe to drop once nothing
|
||||
-- references is_hr_admin() anymore (tracked in docs/decisions).
|
||||
create or replace function is_hr_admin()
|
||||
returns boolean
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select is_hr_user();
|
||||
$$;
|
||||
|
||||
create or replace function require_hr_admin()
|
||||
returns void language plpgsql as $$
|
||||
begin
|
||||
if not is_hr_user() then
|
||||
raise exception 'Nicht berechtigt: nur aktive HR-Benutzer:innen dürfen diese Aktion ausführen.';
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Re-scope every "any authenticated user" read policy to HR-only ──
|
||||
drop policy if exists "org_read" on divisions;
|
||||
create policy "org_read" on divisions for select using (is_hr_user());
|
||||
drop policy if exists "org_write" on divisions;
|
||||
create policy "org_write" on divisions for all using (is_hr_user()) with check (is_hr_user());
|
||||
|
||||
drop policy if exists "org_read" on departments;
|
||||
create policy "org_read" on departments for select using (is_hr_user());
|
||||
drop policy if exists "org_write" on departments;
|
||||
create policy "org_write" on departments for all using (is_hr_user()) with check (is_hr_user());
|
||||
|
||||
drop policy if exists "org_read" on teams;
|
||||
create policy "org_read" on teams for select using (is_hr_user());
|
||||
drop policy if exists "org_write" on teams;
|
||||
create policy "org_write" on teams for all using (is_hr_user()) with check (is_hr_user());
|
||||
|
||||
drop policy if exists "org_read" on locations;
|
||||
create policy "org_read" on locations for select using (is_hr_user());
|
||||
drop policy if exists "org_write" on locations;
|
||||
create policy "org_write" on locations for all using (is_hr_user()) with check (is_hr_user());
|
||||
|
||||
drop policy if exists "history_read" on employee_history;
|
||||
create policy "history_read" on employee_history for select using (is_hr_user());
|
||||
drop policy if exists "history_insert_admin" on employee_history;
|
||||
create policy "history_insert_admin" on employee_history for insert with check (is_hr_user());
|
||||
|
||||
drop policy if exists "positions_read" on positions;
|
||||
create policy "positions_read" on positions for select using (is_hr_user());
|
||||
drop policy if exists "positions_write_admin" on positions;
|
||||
create policy "positions_write_admin" on positions for all using (is_hr_user()) with check (is_hr_user());
|
||||
|
||||
drop policy if exists "audit_read" on audit_log;
|
||||
create policy "audit_read" on audit_log for select using (is_hr_user());
|
||||
drop policy if exists "audit_insert_admin" on audit_log;
|
||||
create policy "audit_insert_admin" on audit_log for insert with check (is_hr_user());
|
||||
|
||||
drop policy if exists "reorg_scenarios_read" on reorg_scenarios;
|
||||
create policy "reorg_scenarios_read" on reorg_scenarios for select using (is_hr_user());
|
||||
drop policy if exists "reorg_scenarios_write_admin" on reorg_scenarios;
|
||||
create policy "reorg_scenarios_write_admin" on reorg_scenarios for all using (is_hr_user()) with check (is_hr_user());
|
||||
|
||||
drop policy if exists "reorg_moves_read" on reorg_moves;
|
||||
create policy "reorg_moves_read" on reorg_moves for select using (is_hr_user());
|
||||
drop policy if exists "reorg_moves_write_admin" on reorg_moves;
|
||||
create policy "reorg_moves_write_admin" on reorg_moves for all using (is_hr_user()) with check (is_hr_user());
|
||||
|
||||
drop policy if exists "employees_admin_all" on employees;
|
||||
create policy "employees_hr_all" on employees for all using (is_hr_user()) with check (is_hr_user());
|
||||
|
||||
-- profiles: users may always read their own row (needed to determine their
|
||||
-- own HR status before is_hr_user() would otherwise apply); HR manages all.
|
||||
drop policy if exists "profiles_select_admin" on profiles;
|
||||
create policy "profiles_select_admin" on profiles for select using (is_hr_user());
|
||||
drop policy if exists "profiles_write_admin" on profiles;
|
||||
create policy "profiles_write_admin" on profiles for insert with check (is_hr_user());
|
||||
drop policy if exists "profiles_update_admin" on profiles;
|
||||
create policy "profiles_update_admin" on profiles for update using (is_hr_user()) with check (is_hr_user());
|
||||
|
||||
-- hire_drafts / saved_reports stay owner-scoped (unchanged) — but an owner
|
||||
-- who is no longer an active HR user should not retain access either.
|
||||
drop policy if exists "hire_drafts_owner" on hire_drafts;
|
||||
create policy "hire_drafts_owner" on hire_drafts for all
|
||||
using (created_by = auth.uid() and is_hr_user()) with check (created_by = auth.uid() and is_hr_user());
|
||||
|
||||
drop policy if exists "saved_reports_owner" on saved_reports;
|
||||
create policy "saved_reports_owner" on saved_reports for all
|
||||
using (created_by = auth.uid() and is_hr_user()) with check (created_by = auth.uid() and is_hr_user());
|
||||
|
||||
-- ── Drop the salary-masking view: no second role left to mask from ──
|
||||
drop view if exists employees_directory;
|
||||
38
supabase/migrations/20260714120050_pending_org_changes.sql
Normal file
38
supabase/migrations/20260714120050_pending_org_changes.sql
Normal file
@@ -0,0 +1,38 @@
|
||||
-- Deferred/effective-dated changes (spec §3.3)
|
||||
--
|
||||
-- Finding from the consolidation review: transfer_employee, promote_employee,
|
||||
-- start_karenz, change_employee_data, and apply_reorg all accepted a
|
||||
-- "Wirksam ab" / effective date, but only ever used it as metadata for the
|
||||
-- employee_history/audit_log rows — the actual `update employees` always
|
||||
-- ran immediately regardless of that date. A transfer or promotion dated
|
||||
-- months in the future silently overwrote the *current* live record today.
|
||||
-- This table backs the fix: when an effective date is in the future, the
|
||||
-- mutating RPC stores the intended change here instead of writing it to
|
||||
-- `employees` right away; a scheduled job (apply_due_pending_changes(),
|
||||
-- see the following migration, invoked by a Vercel Cron route handler)
|
||||
-- applies it once its date arrives. The employee_history/audit_log rows are
|
||||
-- written immediately either way (dated with the effective date), which is
|
||||
-- what already drives the "zukünftig" badge in the Historie tab.
|
||||
|
||||
create table pending_org_changes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
employee_id uuid not null references employees(id) on delete cascade,
|
||||
change_type text not null check (change_type in (
|
||||
'transfer', 'promotion', 'karenz_start', 'karenz_return', 'contract_change', 'reorg'
|
||||
)),
|
||||
effective_date date not null,
|
||||
payload jsonb not null,
|
||||
reorg_scenario_id uuid references reorg_scenarios(id) on delete cascade,
|
||||
status text not null default 'pending' check (status in ('pending', 'applied', 'cancelled')),
|
||||
created_by uuid references auth.users(id),
|
||||
created_at timestamptz not null default now(),
|
||||
applied_at timestamptz
|
||||
);
|
||||
|
||||
create index on pending_org_changes (employee_id);
|
||||
create index on pending_org_changes (status, effective_date);
|
||||
create index on pending_org_changes (reorg_scenario_id);
|
||||
|
||||
alter table pending_org_changes enable row level security;
|
||||
create policy "pending_org_changes_hr_all" on pending_org_changes for all
|
||||
using (is_hr_user()) with check (is_hr_user());
|
||||
125
supabase/migrations/20260714120100_salary_deprecation.sql
Normal file
125
supabase/migrations/20260714120100_salary_deprecation.sql
Normal file
@@ -0,0 +1,125 @@
|
||||
-- Salary out of MVP scope (spec §4)
|
||||
--
|
||||
-- Decision: do NOT drop employees.monthly_salary_gross. This is a live
|
||||
-- Supabase project that may already hold seeded/real rows with values in
|
||||
-- this column; a destructive drop is unrecoverable and unnecessary to
|
||||
-- achieve the actual goal (removing salary from the product surface).
|
||||
-- Instead: relax the column so the app can stop supplying it, mark it
|
||||
-- deprecated, and stop every RPC from reading/writing it. A future
|
||||
-- migration MAY drop the column outright once it's confirmed nothing in
|
||||
-- any environment still depends on it (tracked in docs/decisions).
|
||||
|
||||
alter table employees alter column monthly_salary_gross drop not null;
|
||||
alter table employees drop constraint if exists employees_monthly_salary_gross_check;
|
||||
|
||||
comment on column employees.monthly_salary_gross is
|
||||
'DEPRECATED (2026 consolidation): salary is out of MVP scope. Column kept '
|
||||
'only because it may hold pre-existing data; the application no longer '
|
||||
'reads or writes it (see hire_employee/promote_employee). Candidate for '
|
||||
'a future DROP COLUMN once confirmed unused across all environments.';
|
||||
|
||||
-- hire_employee: stop requiring/writing salary.
|
||||
create or replace function hire_employee(payload jsonb)
|
||||
returns uuid language plpgsql as $$
|
||||
declare
|
||||
v_id uuid;
|
||||
v_team_id uuid;
|
||||
v_division_id uuid;
|
||||
v_position record;
|
||||
v_job_title text;
|
||||
v_email text;
|
||||
v_manager uuid;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
if payload->>'position_id' is not null then
|
||||
select * into v_position from positions where id = (payload->>'position_id')::uuid and status = 'open';
|
||||
if not found then
|
||||
raise exception 'Position ist nicht mehr offen.';
|
||||
end if;
|
||||
v_team_id := v_position.team_id;
|
||||
v_division_id := v_position.division_id;
|
||||
v_job_title := coalesce(payload->>'job_title', v_position.title);
|
||||
else
|
||||
v_team_id := (payload->>'team_id')::uuid;
|
||||
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_team_id;
|
||||
v_job_title := payload->>'job_title';
|
||||
end if;
|
||||
|
||||
v_manager := resolve_manager_for(v_team_id, false, v_division_id);
|
||||
v_email := generate_company_email(payload->>'first_name', payload->>'last_name');
|
||||
|
||||
insert into employees (
|
||||
first_name, last_name, gender, birth_date, sv_nummer, nationality, email, phone,
|
||||
team_id, division_id, job_title, location_id, manager_id, org_level, is_lead,
|
||||
employment_type, weekly_hours, contract_type, contract_end_date,
|
||||
paygrade, source, status, entry_date
|
||||
) values (
|
||||
payload->>'first_name', payload->>'last_name', (payload->>'gender')::gender_type,
|
||||
(payload->>'birth_date')::date, payload->>'sv_nummer', coalesce(payload->>'nationality', 'Österreich'),
|
||||
v_email, payload->>'phone',
|
||||
v_team_id, v_division_id, v_job_title, (payload->>'location_id')::uuid,
|
||||
v_manager, 3, false,
|
||||
coalesce((payload->>'employment_type')::employment_type, 'Vollzeit'),
|
||||
coalesce((payload->>'weekly_hours')::numeric, 38.5),
|
||||
coalesce((payload->>'contract_type')::contract_type, 'unbefristet'),
|
||||
nullif(payload->>'contract_end_date', '')::date,
|
||||
coalesce((payload->>'paygrade')::paygrade_type, 'B'),
|
||||
coalesce((payload->>'source')::source_type, 'Extern'),
|
||||
(case when (payload->>'entry_date')::date > current_date then 'Geplant' else 'Aktiv' end)::employment_status,
|
||||
(payload->>'entry_date')::date
|
||||
) returning id into v_id;
|
||||
|
||||
if payload->>'position_id' is not null then
|
||||
update positions set status = 'filled', filled_at = now(), filled_by_employee_id = v_id
|
||||
where id = (payload->>'position_id')::uuid;
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_id, (payload->>'entry_date')::date, 'Eintritt', 'Eintritt als ' || v_job_title);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Neueinstellung', (payload->>'first_name') || ' ' || (payload->>'last_name'), v_id, 'Eintritt am ' || (payload->>'entry_date'));
|
||||
|
||||
return v_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- promote_employee: no longer accepts/writes new_salary; paygrade remains
|
||||
-- (it's an organizational/functional classification, not a derived salary
|
||||
-- figure — see §4 point 7).
|
||||
create or replace function promote_employee(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||
v_old_paygrade paygrade_type;
|
||||
v_name text;
|
||||
v_details text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select paygrade, first_name || ' ' || last_name into v_old_paygrade, v_name from employees where id = v_employee_id;
|
||||
|
||||
v_details := 'Neue Position: ' || (payload->>'new_title');
|
||||
if payload->>'new_paygrade' is not null and (payload->>'new_paygrade')::paygrade_type <> v_old_paygrade then
|
||||
v_details := v_details || ', neue Paygrade: ' || (payload->>'new_paygrade');
|
||||
end if;
|
||||
|
||||
if v_effective_date <= current_date then
|
||||
update employees set
|
||||
job_title = payload->>'new_title',
|
||||
paygrade = coalesce((payload->>'new_paygrade')::paygrade_type, paygrade)
|
||||
where id = v_employee_id;
|
||||
else
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'promotion', v_effective_date,
|
||||
jsonb_build_object('new_title', payload->>'new_title', 'new_paygrade', payload->>'new_paygrade'));
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_effective_date, 'Beförderung', v_details);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Beförderung', v_name, v_employee_id, v_details);
|
||||
end;
|
||||
$$;
|
||||
387
supabase/migrations/20260714120200_effective_dating_rpcs.sql
Normal file
387
supabase/migrations/20260714120200_effective_dating_rpcs.sql
Normal file
@@ -0,0 +1,387 @@
|
||||
-- Fix effective-dating for Versetzung, Karenz (start + return), Daten
|
||||
-- ändern, and Reorganisation (spec §3.3, §7.14).
|
||||
--
|
||||
-- Pattern used throughout: if the effective/return date is today or in the
|
||||
-- past, behave exactly as before (immediate write). If it's in the future,
|
||||
-- skip the `update employees` and instead record the intended change in
|
||||
-- pending_org_changes; employee_history/audit_log are written immediately
|
||||
-- either way, dated with the effective date (this is what already drives
|
||||
-- the "zukünftig" badge in the Historie tab — unchanged). A separate
|
||||
-- apply_due_pending_changes() function (called by a scheduled job) applies
|
||||
-- due rows once their date arrives, re-resolving manager_id fresh at apply
|
||||
-- time rather than trusting a value computed when the change was requested.
|
||||
|
||||
-- ── Versetzung ───────────────────────────────────────────────────
|
||||
create or replace function transfer_employee(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_new_team_id uuid := (payload->>'new_team_id')::uuid;
|
||||
v_effective_date date := (payload->>'effective_date')::date;
|
||||
v_division_id uuid;
|
||||
v_manager uuid;
|
||||
v_name text;
|
||||
v_is_lead boolean;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_new_team_id;
|
||||
select is_lead, first_name || ' ' || last_name into v_is_lead, v_name from employees where id = v_employee_id;
|
||||
|
||||
if v_effective_date <= current_date then
|
||||
v_manager := resolve_manager_for(v_new_team_id, v_is_lead, v_division_id);
|
||||
update employees set
|
||||
team_id = v_new_team_id,
|
||||
job_title = coalesce(nullif(payload->>'new_title', ''), job_title),
|
||||
manager_id = v_manager
|
||||
where id = v_employee_id;
|
||||
else
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'transfer', v_effective_date,
|
||||
jsonb_build_object('new_team_id', v_new_team_id, 'new_title', payload->>'new_title'));
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_effective_date, 'Versetzung',
|
||||
'Versetzung, wirksam ab ' || (payload->>'effective_date') ||
|
||||
case when payload->>'new_title' is not null and payload->>'new_title' <> '' then ', neue Position: ' || (payload->>'new_title') else '' end);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Versetzung', v_name, v_employee_id, 'Wirksam ab ' || (payload->>'effective_date'));
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Karenz antreten ──────────────────────────────────────────────
|
||||
create or replace function start_karenz(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_start_date date := (payload->>'karenz_start_date')::date;
|
||||
v_name text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
||||
|
||||
if v_start_date <= current_date then
|
||||
update employees set status = 'Karenz', karenz_return_date = (payload->>'planned_return_date')::date
|
||||
where id = v_employee_id;
|
||||
else
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'karenz_start', v_start_date,
|
||||
jsonb_build_object('planned_return_date', payload->>'planned_return_date'));
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_start_date, 'Karenz',
|
||||
'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') ||
|
||||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date'));
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Rückkehr aus Karenz ──────────────────────────────────────────
|
||||
-- status/manager_id/karenz_return_date already correctly waited for the
|
||||
-- return date; employment_type/weekly_hours did not (fixed here).
|
||||
create or replace function record_karenz_return(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_return_date date := (payload->>'return_date')::date;
|
||||
v_name text;
|
||||
v_team_id uuid;
|
||||
v_division_id uuid;
|
||||
v_is_lead boolean;
|
||||
v_manager uuid;
|
||||
v_employment_type employment_type;
|
||||
v_weekly_hours numeric;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name, team_id, division_id, is_lead
|
||||
into v_name, v_team_id, v_division_id, v_is_lead
|
||||
from employees where id = v_employee_id;
|
||||
|
||||
if payload->>'employment_mode' = 'Vollzeit' then
|
||||
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
|
||||
elsif payload->>'employment_mode' = 'Teilzeit' then
|
||||
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
|
||||
end if;
|
||||
|
||||
if v_return_date <= current_date then
|
||||
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
|
||||
update employees set
|
||||
status = 'Aktiv',
|
||||
karenz_return_date = null,
|
||||
manager_id = v_manager,
|
||||
employment_type = coalesce(v_employment_type, employment_type),
|
||||
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
|
||||
where id = v_employee_id;
|
||||
else
|
||||
-- Only record the planned date now; the employment-mode change itself
|
||||
-- (and the status/manager flip) waits for record_karenz_return's
|
||||
-- effective date, applied later by apply_due_pending_changes().
|
||||
update employees set karenz_return_date = v_return_date where id = v_employee_id;
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'karenz_return', v_return_date,
|
||||
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_return_date, 'Rückkehr', 'Wiedereintritt aus Karenz am ' || (payload->>'return_date'));
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || (payload->>'return_date'));
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Daten ändern ─────────────────────────────────────────────────
|
||||
create or replace function change_employee_data(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_effective_date date := coalesce(nullif(payload->>'effective_date', '')::date, current_date);
|
||||
v_old employees%rowtype;
|
||||
v_name text;
|
||||
v_person_changes text[] := '{}';
|
||||
v_contract_changes text[] := '{}';
|
||||
v_person jsonb := payload->'person';
|
||||
v_contract jsonb := payload->'contract';
|
||||
v_immediate boolean;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_old from employees where id = v_employee_id;
|
||||
v_name := v_old.first_name || ' ' || v_old.last_name;
|
||||
v_immediate := v_effective_date <= current_date;
|
||||
|
||||
if v_person ? 'first_name' and (v_person->>'first_name') <> v_old.first_name then v_person_changes := array_append(v_person_changes, 'Vorname'); end if;
|
||||
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := array_append(v_person_changes, 'Nachname'); end if;
|
||||
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := array_append(v_person_changes, 'Geschlecht'); end if;
|
||||
if v_person ? 'birth_date' and (v_person->>'birth_date')::date <> v_old.birth_date then v_person_changes := array_append(v_person_changes, 'Geburtsdatum'); end if;
|
||||
if v_person ? 'sv_nummer' and coalesce(v_person->>'sv_nummer','') <> coalesce(v_old.sv_nummer,'') then v_person_changes := array_append(v_person_changes, 'SV-Nummer'); end if;
|
||||
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := array_append(v_person_changes, 'Staatsbürgerschaft'); end if;
|
||||
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := array_append(v_person_changes, 'Adresse'); end if;
|
||||
if v_person ? 'address_country' and coalesce(v_person->>'address_country','') <> coalesce(v_old.address_country,'') then v_person_changes := array_append(v_person_changes, 'Land'); end if;
|
||||
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := array_append(v_person_changes, 'E-Mail'); end if;
|
||||
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := array_append(v_person_changes, 'Telefon'); end if;
|
||||
|
||||
if v_contract ? 'employment_type' and (v_contract->>'employment_type') <> v_old.employment_type::text then v_contract_changes := array_append(v_contract_changes, 'Beschäftigungsausmaß'); end if;
|
||||
if v_contract ? 'weekly_hours' and (v_contract->>'weekly_hours')::numeric <> v_old.weekly_hours then v_contract_changes := array_append(v_contract_changes, 'Wochenstunden'); end if;
|
||||
if v_contract ? 'contract_type' and (v_contract->>'contract_type') <> v_old.contract_type::text then v_contract_changes := array_append(v_contract_changes, 'Vertragsart'); end if;
|
||||
if v_contract ? 'contract_end_date' and coalesce(nullif(v_contract->>'contract_end_date','')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := array_append(v_contract_changes, 'Befristet bis'); end if;
|
||||
|
||||
if v_immediate then
|
||||
update employees set
|
||||
first_name = coalesce(v_person->>'first_name', first_name),
|
||||
last_name = coalesce(v_person->>'last_name', last_name),
|
||||
gender = coalesce((v_person->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_person->>'birth_date')::date, birth_date),
|
||||
sv_nummer = coalesce(v_person->>'sv_nummer', sv_nummer),
|
||||
nationality = coalesce(v_person->>'nationality', nationality),
|
||||
address = coalesce(v_person->>'address', address),
|
||||
address_country = coalesce(v_person->>'address_country', address_country),
|
||||
email = coalesce(v_person->>'email', email),
|
||||
phone = coalesce(v_person->>'phone', phone),
|
||||
employment_type = coalesce((v_contract->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_contract->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_contract->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when v_contract ? 'contract_end_date' then nullif(v_contract->>'contract_end_date','')::date else contract_end_date end
|
||||
where id = v_employee_id;
|
||||
elsif array_length(v_person_changes, 1) > 0 or array_length(v_contract_changes, 1) > 0 then
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'contract_change', v_effective_date, payload);
|
||||
end if;
|
||||
|
||||
if array_length(v_person_changes, 1) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_effective_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Stammdatenänderung', v_name, v_employee_id, array_to_string(v_person_changes, ', ') || ', wirksam ab ' || v_effective_date);
|
||||
end if;
|
||||
|
||||
if array_length(v_contract_changes, 1) > 0 then
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_effective_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Vertragsänderung', v_name, v_employee_id, array_to_string(v_contract_changes, ', ') || ', wirksam ab ' || v_effective_date);
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Reorganisation ───────────────────────────────────────────────
|
||||
-- Immediate moves (effective_date <= today) behave exactly as before.
|
||||
-- Future-dated scenarios write no employee mutations at all yet; one
|
||||
-- pending_org_changes row per affected employee is queued instead, and
|
||||
-- reorg_scenarios.applied stays false until every one of them is applied.
|
||||
create or replace function apply_reorg(payload jsonb)
|
||||
returns uuid language plpgsql as $$
|
||||
declare
|
||||
v_scenario_id uuid;
|
||||
v_effective_date date := (payload->>'effective_date')::date;
|
||||
v_immediate boolean := (payload->>'effective_date')::date <= current_date;
|
||||
v_move jsonb;
|
||||
v_employee_id text;
|
||||
v_target_team_id uuid;
|
||||
v_division_id uuid;
|
||||
v_is_lead boolean;
|
||||
v_manager uuid;
|
||||
v_snapshot jsonb := '{}'::jsonb;
|
||||
v_old record;
|
||||
v_total_moves int := 0;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
|
||||
insert into reorg_scenarios (name, effective_date, created_by, applied, applied_at)
|
||||
values (payload->>'name', v_effective_date, auth.uid(), v_immediate, case when v_immediate then now() else null end)
|
||||
returning id into v_scenario_id;
|
||||
|
||||
for v_move in select * from jsonb_array_elements(payload->'moves')
|
||||
loop
|
||||
v_target_team_id := (v_move->>'target_team_id')::uuid;
|
||||
select division_id into v_division_id from teams t join departments d on d.id = t.department_id where t.id = v_target_team_id;
|
||||
|
||||
insert into reorg_moves (scenario_id, kind, payload) values (v_scenario_id, v_move->>'kind', v_move);
|
||||
|
||||
for v_employee_id in select jsonb_array_elements_text(v_move->'employee_ids')
|
||||
loop
|
||||
select * into v_old from employees where id = v_employee_id::uuid;
|
||||
v_snapshot := v_snapshot || jsonb_build_object(v_employee_id, jsonb_build_object(
|
||||
'team_id', v_old.team_id, 'division_id', v_old.division_id, 'manager_id', v_old.manager_id
|
||||
));
|
||||
|
||||
if v_immediate then
|
||||
v_is_lead := v_old.is_lead;
|
||||
v_manager := resolve_manager_for(v_target_team_id, v_is_lead, v_division_id);
|
||||
update employees set team_id = v_target_team_id, manager_id = v_manager where id = v_employee_id::uuid;
|
||||
else
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload, reorg_scenario_id)
|
||||
values (v_employee_id::uuid, 'reorg', v_effective_date,
|
||||
jsonb_build_object('target_team_id', v_target_team_id), v_scenario_id);
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id)
|
||||
values (v_employee_id::uuid, v_effective_date, 'Reorganisation',
|
||||
'Reorganisation "' || (payload->>'name') || '": neues Team zugewiesen', v_scenario_id);
|
||||
|
||||
v_total_moves := v_total_moves + 1;
|
||||
end loop;
|
||||
end loop;
|
||||
|
||||
update reorg_scenarios set undo_snapshot = v_snapshot where id = v_scenario_id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
||||
values (auth.uid(), current_actor_name(), 'Reorganisation', payload->>'name', v_total_moves || ' Mitarbeiter:innen betroffen');
|
||||
|
||||
return v_scenario_id;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- ── Applies every due pending change ─────────────────────────────
|
||||
-- Invoked by the /api/cron/apply-pending-changes route handler (Vercel
|
||||
-- Cron, daily) using the service-role client — this is a system process,
|
||||
-- not a user action, so it does not go through require_hr_admin(); it is
|
||||
-- SECURITY DEFINER precisely so it can run outside any HR user's session.
|
||||
create or replace function apply_due_pending_changes()
|
||||
returns int
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_rec record;
|
||||
v_team_id uuid;
|
||||
v_division_id uuid;
|
||||
v_is_lead boolean;
|
||||
v_manager uuid;
|
||||
v_remaining int;
|
||||
v_applied_count int := 0;
|
||||
begin
|
||||
for v_rec in
|
||||
select * from pending_org_changes
|
||||
where status = 'pending' and effective_date <= current_date
|
||||
order by created_at
|
||||
loop
|
||||
if v_rec.change_type = 'transfer' then
|
||||
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
|
||||
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
|
||||
where t.id = (v_rec.payload->>'new_team_id')::uuid;
|
||||
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
|
||||
update employees set
|
||||
team_id = (v_rec.payload->>'new_team_id')::uuid,
|
||||
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
|
||||
manager_id = v_manager
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'promotion' then
|
||||
update employees set
|
||||
job_title = coalesce(v_rec.payload->>'new_title', job_title),
|
||||
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'karenz_start' then
|
||||
update employees set status = 'Karenz', karenz_return_date = (v_rec.payload->>'planned_return_date')::date
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'karenz_return' then
|
||||
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
|
||||
from employees where id = v_rec.employee_id;
|
||||
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
|
||||
update employees set
|
||||
status = 'Aktiv',
|
||||
karenz_return_date = null,
|
||||
manager_id = v_manager,
|
||||
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'contract_change' then
|
||||
update employees set
|
||||
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
|
||||
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
|
||||
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
|
||||
sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
|
||||
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
|
||||
address = coalesce(v_rec.payload->'person'->>'address', address),
|
||||
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
|
||||
email = coalesce(v_rec.payload->'person'->>'email', email),
|
||||
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
|
||||
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
|
||||
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date else contract_end_date end
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'reorg' then
|
||||
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
|
||||
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
|
||||
where t.id = (v_rec.payload->>'target_team_id')::uuid;
|
||||
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
|
||||
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
|
||||
where id = v_rec.employee_id;
|
||||
end if;
|
||||
|
||||
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
|
||||
v_applied_count := v_applied_count + 1;
|
||||
|
||||
if v_rec.reorg_scenario_id is not null then
|
||||
select count(*) into v_remaining from pending_org_changes
|
||||
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
|
||||
if v_remaining = 0 then
|
||||
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
|
||||
end if;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
return v_applied_count;
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- This bypasses RLS (SECURITY DEFINER) by design so the daily cron job can
|
||||
-- run it without any HR user session — which means it must NOT be callable
|
||||
-- by ordinary app roles (an authenticated HR user calling it early would
|
||||
-- force-apply not-yet-due changes ahead of their effective date).
|
||||
revoke execute on function apply_due_pending_changes() from public;
|
||||
revoke execute on function apply_due_pending_changes() from anon;
|
||||
revoke execute on function apply_due_pending_changes() from authenticated;
|
||||
grant execute on function apply_due_pending_changes() to service_role;
|
||||
@@ -0,0 +1,59 @@
|
||||
-- Make reorg undo respect employee_history's append-only contract
|
||||
-- (spec §6.1: "append-only, keine normale
|
||||
-- Update-/Delete-Funktion").
|
||||
--
|
||||
-- The previous undo_reorg deleted the employee_history rows a reorg had
|
||||
-- created (functions.sql:549), which required a narrow RLS carve-out
|
||||
-- (functions_3.sql's "history_delete_admin_reorg_undo" policy) allowing
|
||||
-- hr_admin to delete reorg-tagged history rows. That is the one place in
|
||||
-- the whole schema where history was not actually immutable. Fixed by
|
||||
-- appending a compensating "Reorganisation rückgängig" history entry per
|
||||
-- affected employee instead of deleting anything — the original
|
||||
-- Reorganisation rows stay in the record, exactly like every other history
|
||||
-- event type. The now-unused delete policy is dropped.
|
||||
|
||||
drop policy if exists "history_delete_admin_reorg_undo" on employee_history;
|
||||
|
||||
create or replace function undo_reorg(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_scenario record;
|
||||
v_key text;
|
||||
v_val jsonb;
|
||||
v_name text;
|
||||
v_count int := 0;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select * into v_scenario from reorg_scenarios where id = (payload->>'scenario_id')::uuid and applied = true;
|
||||
if not found or v_scenario.undo_snapshot is null then
|
||||
raise exception 'Reorganisation kann nicht rückgängig gemacht werden (kein Snapshot vorhanden).';
|
||||
end if;
|
||||
|
||||
-- Any pending (not-yet-applied) deferred moves belonging to this scenario
|
||||
-- are cancelled rather than left to fire later against a since-reverted
|
||||
-- state.
|
||||
update pending_org_changes set status = 'cancelled'
|
||||
where reorg_scenario_id = v_scenario.id and status = 'pending';
|
||||
|
||||
for v_key, v_val in select * from jsonb_each(v_scenario.undo_snapshot)
|
||||
loop
|
||||
update employees set
|
||||
team_id = nullif(v_val->>'team_id','')::uuid,
|
||||
division_id = (v_val->>'division_id')::uuid,
|
||||
manager_id = nullif(v_val->>'manager_id','')::uuid
|
||||
where id = v_key::uuid;
|
||||
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_key::uuid;
|
||||
insert into employee_history (employee_id, event_date, event_type, description, reorg_scenario_id)
|
||||
values (v_key::uuid, current_date, 'Reorganisation',
|
||||
'Reorganisation "' || v_scenario.name || '" rückgängig gemacht — vorheriges Team wiederhergestellt', v_scenario.id);
|
||||
|
||||
v_count := v_count + 1;
|
||||
end loop;
|
||||
|
||||
update reorg_scenarios set applied = false where id = v_scenario.id;
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, details)
|
||||
values (auth.uid(), current_actor_name(), 'Reorganisation rückgängig', v_scenario.name, v_count || ' Mitarbeiter:innen zurückgesetzt');
|
||||
end;
|
||||
$$;
|
||||
33
supabase/migrations/20260714120400_performance_indexes.sql
Normal file
33
supabase/migrations/20260714120400_performance_indexes.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Performance indexes (spec §15).
|
||||
--
|
||||
-- Existing indexes already cover employees(team_id/division_id/manager_id/
|
||||
-- status), employee_history(employee_id, event_date desc), positions(team_id/
|
||||
-- status), audit_log(occurred_at desc), and the new pending_org_changes
|
||||
-- table's own indexes (see its migration). This adds the gaps: location-based
|
||||
-- filtering, entry/exit date range queries (dashboard "upcoming" widget +
|
||||
-- Eintritte/Austritte reports), personnel-number lookup (also enforces the
|
||||
-- uniqueness a personnel number should have, which the identity column
|
||||
-- alone did not), reorg/audit foreign-key lookups, and trigram search
|
||||
-- support for the employee list's free-text search box.
|
||||
|
||||
create unique index if not exists idx_employees_personnel_number on employees (personnel_number);
|
||||
create index if not exists idx_employees_location_id on employees (location_id);
|
||||
create index if not exists idx_employees_entry_date on employees (entry_date);
|
||||
create index if not exists idx_employees_exit_date on employees (exit_date) where exit_date is not null;
|
||||
create index if not exists idx_employees_karenz_return_date on employees (karenz_return_date) where karenz_return_date is not null;
|
||||
|
||||
create index if not exists idx_audit_log_target_employee_id on audit_log (target_employee_id) where target_employee_id is not null;
|
||||
create index if not exists idx_employee_history_reorg_scenario_id on employee_history (reorg_scenario_id) where reorg_scenario_id is not null;
|
||||
create index if not exists idx_employee_history_event_type on employee_history (event_type);
|
||||
create index if not exists idx_reorg_scenarios_applied on reorg_scenarios (applied, applied_at desc);
|
||||
create index if not exists idx_positions_division_id on positions (division_id);
|
||||
|
||||
-- Free-text search over name/title (Mitarbeiter:innen list search box) —
|
||||
-- trigram index so ILIKE '%term%' queries can use an index instead of a
|
||||
-- sequential scan.
|
||||
create extension if not exists pg_trgm;
|
||||
|
||||
create index if not exists idx_employees_name_trgm
|
||||
on employees using gin ((first_name || ' ' || last_name) gin_trgm_ops);
|
||||
create index if not exists idx_employees_job_title_trgm
|
||||
on employees using gin (job_title gin_trgm_ops);
|
||||
25
supabase/migrations/20260714120500_default_grants.sql
Normal file
25
supabase/migrations/20260714120500_default_grants.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Explicit schema/table/sequence/routine grants for anon/authenticated/
|
||||
-- service_role.
|
||||
--
|
||||
-- Found while standing up a local Supabase instance to run the integration
|
||||
-- test suite (spec §17): every table in this schema
|
||||
-- relies on RLS policies to restrict access, but RLS only ever *narrows*
|
||||
-- what an already-GRANTed role may do — Postgres still checks ordinary
|
||||
-- object privileges first, independent of a role's BYPASSRLS flag. On
|
||||
-- Supabase's hosted platform this GRANT setup is applied automatically
|
||||
-- during project provisioning, so it was invisible here: every previous
|
||||
-- migration worked fine against the already-provisioned hosted project, but
|
||||
-- the exact same migrations against a *fresh* Postgres (a new local dev
|
||||
-- instance, a disaster-recovery restore, or CI) fail with "permission
|
||||
-- denied for table X" even for the service role, since that grant was never
|
||||
-- actually part of this repo's own migrations. Making it explicit here
|
||||
-- makes the schema fully self-contained and reprovisionable from scratch.
|
||||
grant usage on schema public to anon, authenticated, service_role;
|
||||
|
||||
grant all on all tables in schema public to anon, authenticated, service_role;
|
||||
grant all on all sequences in schema public to anon, authenticated, service_role;
|
||||
grant all on all routines in schema public to anon, authenticated, service_role;
|
||||
|
||||
alter default privileges in schema public grant all on tables to anon, authenticated, service_role;
|
||||
alter default privileges in schema public grant all on sequences to anon, authenticated, service_role;
|
||||
alter default privileges in schema public grant all on routines to anon, authenticated, service_role;
|
||||
248
supabase/migrations/20260714120600_data_integrity_guards.sql
Normal file
248
supabase/migrations/20260714120600_data_integrity_guards.sql
Normal file
@@ -0,0 +1,248 @@
|
||||
-- Two data-integrity rules named explicitly in the spec (§3.7, §11 test scenarios #14 and #17) that had no enforcement
|
||||
-- anywhere — not in the UI, not in a Server Action, not as a DB constraint:
|
||||
--
|
||||
-- 1. "Rückkehr darf nicht vor Beginn der Karenz liegen" — there was no
|
||||
-- column tracking when a Karenz period actually started (only a
|
||||
-- free-text history row), so this could not be validated at all.
|
||||
-- 2. "Historieneinträge dürfen nicht vor dem Eintritt liegen" — nothing
|
||||
-- stopped an employee_history row from being inserted with an
|
||||
-- event_date earlier than that employee's entry_date.
|
||||
|
||||
-- ── 1. Track karenz_start_date; validate return against it ──────────
|
||||
alter table employees add column if not exists karenz_start_date date;
|
||||
|
||||
create or replace function start_karenz(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_start_date date := (payload->>'karenz_start_date')::date;
|
||||
v_name text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name into v_name from employees where id = v_employee_id;
|
||||
|
||||
if v_start_date <= current_date then
|
||||
update employees set status = 'Karenz', karenz_start_date = v_start_date,
|
||||
karenz_return_date = (payload->>'planned_return_date')::date
|
||||
where id = v_employee_id;
|
||||
else
|
||||
update employees set karenz_start_date = v_start_date where id = v_employee_id;
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'karenz_start', v_start_date,
|
||||
jsonb_build_object('planned_return_date', payload->>'planned_return_date'));
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_start_date, 'Karenz',
|
||||
'Karenzantritt, geplante Rückkehr am ' || (payload->>'planned_return_date') ||
|
||||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Karenzantritt, geplante Rückkehr ' || (payload->>'planned_return_date'));
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function adjust_karenz_return(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_new_return_date date := (payload->>'new_return_date')::date;
|
||||
v_karenz_start date;
|
||||
v_name text;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name, karenz_start_date into v_name, v_karenz_start from employees where id = v_employee_id;
|
||||
|
||||
if v_karenz_start is not null and v_new_return_date <= v_karenz_start then
|
||||
raise exception 'Das Rückkehrdatum muss nach dem Karenzbeginn (%) liegen.', v_karenz_start;
|
||||
end if;
|
||||
|
||||
update employees set karenz_return_date = v_new_return_date where id = v_employee_id;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, current_date, 'Karenz',
|
||||
'Rückkehrdatum angepasst auf ' || (payload->>'new_return_date') ||
|
||||
case when payload->>'note' is not null and payload->>'note' <> '' then ' — ' || (payload->>'note') else '' end);
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Karenz', v_name, v_employee_id, 'Neues Rückkehrdatum: ' || (payload->>'new_return_date'));
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function record_karenz_return(payload jsonb)
|
||||
returns void language plpgsql as $$
|
||||
declare
|
||||
v_employee_id uuid := (payload->>'employee_id')::uuid;
|
||||
v_return_date date := (payload->>'return_date')::date;
|
||||
v_name text;
|
||||
v_team_id uuid;
|
||||
v_division_id uuid;
|
||||
v_is_lead boolean;
|
||||
v_manager uuid;
|
||||
v_employment_type employment_type;
|
||||
v_weekly_hours numeric;
|
||||
v_karenz_start date;
|
||||
begin
|
||||
perform require_hr_admin();
|
||||
select first_name || ' ' || last_name, team_id, division_id, is_lead, karenz_start_date
|
||||
into v_name, v_team_id, v_division_id, v_is_lead, v_karenz_start
|
||||
from employees where id = v_employee_id;
|
||||
|
||||
if v_karenz_start is not null and v_return_date <= v_karenz_start then
|
||||
raise exception 'Das Rückkehrdatum muss nach dem Karenzbeginn (%) liegen.', v_karenz_start;
|
||||
end if;
|
||||
|
||||
if payload->>'employment_mode' = 'Vollzeit' then
|
||||
v_employment_type := 'Vollzeit'; v_weekly_hours := 38.5;
|
||||
elsif payload->>'employment_mode' = 'Teilzeit' then
|
||||
v_employment_type := 'Teilzeit'; v_weekly_hours := (payload->>'weekly_hours')::numeric;
|
||||
end if;
|
||||
|
||||
if v_return_date <= current_date then
|
||||
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
|
||||
update employees set
|
||||
status = 'Aktiv',
|
||||
karenz_return_date = null,
|
||||
karenz_start_date = null,
|
||||
manager_id = v_manager,
|
||||
employment_type = coalesce(v_employment_type, employment_type),
|
||||
weekly_hours = coalesce(v_weekly_hours, weekly_hours)
|
||||
where id = v_employee_id;
|
||||
else
|
||||
update employees set karenz_return_date = v_return_date where id = v_employee_id;
|
||||
insert into pending_org_changes (employee_id, change_type, effective_date, payload)
|
||||
values (v_employee_id, 'karenz_return', v_return_date,
|
||||
jsonb_build_object('employment_type', v_employment_type, 'weekly_hours', v_weekly_hours));
|
||||
end if;
|
||||
|
||||
insert into employee_history (employee_id, event_date, event_type, description)
|
||||
values (v_employee_id, v_return_date, 'Rückkehr', 'Wiedereintritt aus Karenz am ' || (payload->>'return_date'));
|
||||
|
||||
insert into audit_log (actor_user_id, actor_name, action, target_label, target_employee_id, details)
|
||||
values (auth.uid(), current_actor_name(), 'Rückkehr', v_name, v_employee_id, 'Rückkehr am ' || (payload->>'return_date'));
|
||||
end;
|
||||
$$;
|
||||
|
||||
-- apply_due_pending_changes: clear karenz_start_date once the deferred
|
||||
-- return actually applies (mirrors the immediate branch above).
|
||||
create or replace function apply_due_pending_changes()
|
||||
returns int
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_rec record;
|
||||
v_team_id uuid;
|
||||
v_division_id uuid;
|
||||
v_is_lead boolean;
|
||||
v_manager uuid;
|
||||
v_remaining int;
|
||||
v_applied_count int := 0;
|
||||
begin
|
||||
for v_rec in
|
||||
select * from pending_org_changes
|
||||
where status = 'pending' and effective_date <= current_date
|
||||
order by created_at
|
||||
loop
|
||||
if v_rec.change_type = 'transfer' then
|
||||
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
|
||||
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
|
||||
where t.id = (v_rec.payload->>'new_team_id')::uuid;
|
||||
v_manager := resolve_manager_for((v_rec.payload->>'new_team_id')::uuid, v_is_lead, v_division_id);
|
||||
update employees set
|
||||
team_id = (v_rec.payload->>'new_team_id')::uuid,
|
||||
job_title = coalesce(nullif(v_rec.payload->>'new_title', ''), job_title),
|
||||
manager_id = v_manager
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'promotion' then
|
||||
update employees set
|
||||
job_title = coalesce(v_rec.payload->>'new_title', job_title),
|
||||
paygrade = coalesce((v_rec.payload->>'new_paygrade')::paygrade_type, paygrade)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'karenz_start' then
|
||||
update employees set status = 'Karenz', karenz_return_date = (v_rec.payload->>'planned_return_date')::date
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'karenz_return' then
|
||||
select team_id, division_id, is_lead into v_team_id, v_division_id, v_is_lead
|
||||
from employees where id = v_rec.employee_id;
|
||||
v_manager := resolve_manager_for(v_team_id, v_is_lead, v_division_id);
|
||||
update employees set
|
||||
status = 'Aktiv',
|
||||
karenz_return_date = null,
|
||||
karenz_start_date = null,
|
||||
manager_id = v_manager,
|
||||
employment_type = coalesce((v_rec.payload->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_rec.payload->>'weekly_hours')::numeric, weekly_hours)
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'contract_change' then
|
||||
update employees set
|
||||
first_name = coalesce(v_rec.payload->'person'->>'first_name', first_name),
|
||||
last_name = coalesce(v_rec.payload->'person'->>'last_name', last_name),
|
||||
gender = coalesce((v_rec.payload->'person'->>'gender')::gender_type, gender),
|
||||
birth_date = coalesce((v_rec.payload->'person'->>'birth_date')::date, birth_date),
|
||||
sv_nummer = coalesce(v_rec.payload->'person'->>'sv_nummer', sv_nummer),
|
||||
nationality = coalesce(v_rec.payload->'person'->>'nationality', nationality),
|
||||
address = coalesce(v_rec.payload->'person'->>'address', address),
|
||||
address_country = coalesce(v_rec.payload->'person'->>'address_country', address_country),
|
||||
email = coalesce(v_rec.payload->'person'->>'email', email),
|
||||
phone = coalesce(v_rec.payload->'person'->>'phone', phone),
|
||||
employment_type = coalesce((v_rec.payload->'contract'->>'employment_type')::employment_type, employment_type),
|
||||
weekly_hours = coalesce((v_rec.payload->'contract'->>'weekly_hours')::numeric, weekly_hours),
|
||||
contract_type = coalesce((v_rec.payload->'contract'->>'contract_type')::contract_type, contract_type),
|
||||
contract_end_date = case when v_rec.payload->'contract' ? 'contract_end_date'
|
||||
then nullif(v_rec.payload->'contract'->>'contract_end_date','')::date else contract_end_date end
|
||||
where id = v_rec.employee_id;
|
||||
|
||||
elsif v_rec.change_type = 'reorg' then
|
||||
select is_lead into v_is_lead from employees where id = v_rec.employee_id;
|
||||
select division_id into v_division_id from teams t join departments d on d.id = t.department_id
|
||||
where t.id = (v_rec.payload->>'target_team_id')::uuid;
|
||||
v_manager := resolve_manager_for((v_rec.payload->>'target_team_id')::uuid, v_is_lead, v_division_id);
|
||||
update employees set team_id = (v_rec.payload->>'target_team_id')::uuid, manager_id = v_manager
|
||||
where id = v_rec.employee_id;
|
||||
end if;
|
||||
|
||||
update pending_org_changes set status = 'applied', applied_at = now() where id = v_rec.id;
|
||||
v_applied_count := v_applied_count + 1;
|
||||
|
||||
if v_rec.reorg_scenario_id is not null then
|
||||
select count(*) into v_remaining from pending_org_changes
|
||||
where reorg_scenario_id = v_rec.reorg_scenario_id and status = 'pending';
|
||||
if v_remaining = 0 then
|
||||
update reorg_scenarios set applied = true, applied_at = now() where id = v_rec.reorg_scenario_id;
|
||||
end if;
|
||||
end if;
|
||||
end loop;
|
||||
|
||||
return v_applied_count;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke execute on function apply_due_pending_changes() from public, anon, authenticated;
|
||||
grant execute on function apply_due_pending_changes() to service_role;
|
||||
|
||||
-- ── 2. History entries may never predate the employee's entry date ───
|
||||
create or replace function fn_check_history_not_before_entry()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
declare
|
||||
v_entry_date date;
|
||||
begin
|
||||
select entry_date into v_entry_date from employees where id = new.employee_id;
|
||||
if v_entry_date is not null and new.event_date < v_entry_date then
|
||||
raise exception 'Historieneintrag (%) darf nicht vor dem Eintrittsdatum (%) liegen.', new.event_date, v_entry_date;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_history_not_before_entry on employee_history;
|
||||
create trigger trg_history_not_before_entry
|
||||
before insert on employee_history
|
||||
for each row execute function fn_check_history_not_before_entry();
|
||||
105
supabase/seed.ts
105
supabase/seed.ts
@@ -16,7 +16,6 @@ if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
|
||||
}
|
||||
|
||||
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 },
|
||||
@@ -106,6 +105,17 @@ function addressCountryFor(nationality: string): string {
|
||||
}
|
||||
|
||||
const STREETS = ["Hauptstraße", "Bahnhofstraße", "Schulgasse", "Kirchenplatz", "Gartenweg", "Industriestraße", "Ringstraße", "Feldweg"];
|
||||
|
||||
// Home address should be plausible for the employee's actual work location,
|
||||
// not a one-size-fits-all Vienna postal code regardless of where they're
|
||||
// based (found during the consolidation review).
|
||||
const HOME_LOCALE_BY_LOCATION: Record<string, { city: string; postal: () => string }> = {
|
||||
"Wien-Hernals": { city: "Wien", postal: () => String(randInt(1100, 1230)) },
|
||||
Wolkersdorf: { city: "Wolkersdorf", postal: () => "2120" },
|
||||
Köln: { city: "Köln", postal: () => String(randInt(50667, 51149)) },
|
||||
Brünn: { city: "Brno", postal: () => `${randInt(600, 664)} ${randInt(10, 99)}` },
|
||||
Ljubljana: { city: "Ljubljana", postal: () => "1000" },
|
||||
};
|
||||
const EXIT_REASONS = [
|
||||
"Einvernehmliche Auflösung", "Kündigung AN", "Kündigung AG", "Befristungsablauf", "Pensionierung", "Entlassung",
|
||||
];
|
||||
@@ -321,7 +331,6 @@ type EmployeeRow = {
|
||||
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";
|
||||
@@ -367,21 +376,13 @@ function birthDateForAge(age: number): Date {
|
||||
return new Date(year, randInt(0, 11), randInt(1, 28));
|
||||
}
|
||||
|
||||
function paygradeAndSalaryForIc(): { paygrade: EmployeeRow["paygrade"]; salary: number } {
|
||||
const grade = weightedPick<EmployeeRow["paygrade"]>([
|
||||
function paygradeForIc(): EmployeeRow["paygrade"] {
|
||||
return 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) {
|
||||
@@ -390,6 +391,7 @@ function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId
|
||||
const lastName = pick(LAST_NAMES);
|
||||
const nationality = weightedPick(NATIONALITIES);
|
||||
const location = weightedPick(LOCATION_WEIGHTS);
|
||||
const homeLocale = HOME_LOCALE_BY_LOCATION[location.name];
|
||||
|
||||
return {
|
||||
id: randomUUID(),
|
||||
@@ -397,7 +399,7 @@ function newHireBase(jobTitle: string, orgLevel: number, isLead: boolean, teamId
|
||||
last_name: lastName,
|
||||
gender,
|
||||
nationality,
|
||||
address: `${pick(STREETS)} ${randInt(1, 90)}, ${randInt(1010, 2500)} Wien`,
|
||||
address: `${pick(STREETS)} ${randInt(1, 90)}, ${homeLocale.postal()} ${homeLocale.city}`,
|
||||
address_country: addressCountryFor(nationality),
|
||||
email: makeEmail(firstName, lastName),
|
||||
phone: `+43 664 ${randInt(1000000, 9999999)}`,
|
||||
@@ -415,7 +417,7 @@ const employees: EmployeeRow[] = [];
|
||||
const history: HistoryRow[] = [];
|
||||
const icPoolForStatusAssignment: EmployeeRow[] = [];
|
||||
|
||||
function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { salary: number; paygrade: EmployeeRow["paygrade"] }): EmployeeRow {
|
||||
function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { paygrade: EmployeeRow["paygrade"] }): EmployeeRow {
|
||||
const age = randInt(22, 60);
|
||||
const birthDate = birthDateForAge(age);
|
||||
const maxTenureYears = Math.min(15, age - 20);
|
||||
@@ -433,7 +435,6 @@ function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { salary:
|
||||
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,
|
||||
@@ -461,16 +462,6 @@ function finalizeEmployee(base: ReturnType<typeof newHireBase>, opts: { salary:
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -489,11 +480,11 @@ divisionRows.push({ id: gfDivisionId, org_number: "20900000", name: "Geschäftsf
|
||||
|
||||
const ceo = finalizeEmployee(
|
||||
newHireBase("Geschäftsführer:in", 0, true, null, gfDivisionId, null),
|
||||
{ salary: 15500, paygrade: "F" }
|
||||
{ paygrade: "F" }
|
||||
);
|
||||
const gfAssistant = finalizeEmployee(
|
||||
newHireBase("Assistenz der Geschäftsführung", 3, false, null, gfDivisionId, ceo.id),
|
||||
{ salary: 3900, paygrade: "C" }
|
||||
{ paygrade: "C" }
|
||||
);
|
||||
employees.push(ceo, gfAssistant);
|
||||
|
||||
@@ -509,7 +500,7 @@ for (const div of DIVISIONS) {
|
||||
|
||||
const divisionHead = finalizeEmployee(
|
||||
newHireBase(div.headTitle, 1, true, null, divisionId, ceo.id),
|
||||
{ salary: randInt(9000, 11500), paygrade: "F" }
|
||||
{ paygrade: "F" }
|
||||
);
|
||||
employees.push(divisionHead);
|
||||
|
||||
@@ -529,16 +520,16 @@ for (const div of DIVISIONS) {
|
||||
|
||||
const teamLead = finalizeEmployee(
|
||||
newHireBase(team.leadTitle, 2, true, teamId, divisionId, divisionHead.id),
|
||||
{ salary: randInt(5000, 6800), paygrade: "E" }
|
||||
{ paygrade: "E" }
|
||||
);
|
||||
employees.push(teamLead);
|
||||
|
||||
for (let i = 0; i < size - 1; i++) {
|
||||
const jobTitle = pick(team.icTitles);
|
||||
const { paygrade, salary } = paygradeAndSalaryForIc();
|
||||
const paygrade = paygradeForIc();
|
||||
const ic = finalizeEmployee(
|
||||
newHireBase(jobTitle, 3, false, teamId, divisionId, teamLead.id),
|
||||
{ salary, paygrade }
|
||||
{ paygrade }
|
||||
);
|
||||
employees.push(ic);
|
||||
icPoolForStatusAssignment.push(ic);
|
||||
@@ -586,12 +577,22 @@ for (let i = 0; i < 12 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||
});
|
||||
}
|
||||
|
||||
// ~3 Geplant (future entry — overwrite entry_date/history)
|
||||
// ~3 Geplant (future entry). A person who hasn't started yet can't already
|
||||
// have a Beförderung or other history predating that future entry date —
|
||||
// found during the consolidation review that reassigning an already-
|
||||
// finalized IC to Geplant only patched their Eintritt row's date, leaving
|
||||
// any earlier-generated history (e.g. a Beförderung) still on the record
|
||||
// with a date before the (now future) entry_date. Fixed by dropping every
|
||||
// history row for that employee except Eintritt, then moving Eintritt to
|
||||
// the new future date.
|
||||
for (let i = 0; i < 3 && cursor < shuffledIcs.length; i++, cursor++) {
|
||||
const e = shuffledIcs[cursor];
|
||||
const futureEntry = addDays(TODAY, randInt(10, 90));
|
||||
e.status = "Geplant";
|
||||
e.entry_date = isoDate(futureEntry);
|
||||
for (let hi = history.length - 1; hi >= 0; hi--) {
|
||||
if (history[hi].employee_id === e.id && history[hi].event_type !== "Eintritt") history.splice(hi, 1);
|
||||
}
|
||||
const historyEntry = history.find((h) => h.employee_id === e.id && h.event_type === "Eintritt");
|
||||
if (historyEntry) historyEntry.event_date = e.entry_date;
|
||||
}
|
||||
@@ -666,40 +667,30 @@ async function main() {
|
||||
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({
|
||||
// The app is HR-only now (see docs/decisions/0001-hr-only-access.md) — no
|
||||
// second "manager" role exists to seed a test account for. This is the
|
||||
// one deliberate, explicit bootstrap grant of HR access (not an automatic
|
||||
// one): every other new profile row defaults to is_active = false and
|
||||
// must be activated by an existing HR user (§2.3).
|
||||
console.log("Creating initial HR account...");
|
||||
const hrPassword = randomUUID().slice(0, 12) + "!Aa1";
|
||||
const { data: hrUser, error: hrErr } = await supabase.auth.admin.createUser({
|
||||
email: ADMIN_EMAIL,
|
||||
password: adminPassword,
|
||||
password: hrPassword,
|
||||
email_confirm: true,
|
||||
});
|
||||
if (adminErr) throw new Error(`Creating admin user failed: ${adminErr.message}`);
|
||||
if (hrErr) throw new Error(`Creating HR user failed: ${hrErr.message}`);
|
||||
await supabase.from("profiles").insert({
|
||||
id: adminUser.user.id,
|
||||
id: hrUser.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",
|
||||
role: "hr",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
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.)");
|
||||
console.log(`HR login: ${ADMIN_EMAIL} / ${hrPassword}`);
|
||||
console.log("(Password is shown once here only — store it somewhere safe.)");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
93
tests/integration/authorization.test.ts
Normal file
93
tests/integration/authorization.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
adminClient,
|
||||
createBareAuthUser,
|
||||
createHrUser,
|
||||
deleteTestUser,
|
||||
signInAs,
|
||||
type TestUser,
|
||||
} from "./helpers";
|
||||
|
||||
// HR-only access model (supabase/migrations/20260714120000_hr_only_access.sql):
|
||||
// nobody except an active, explicitly-provisioned HR user may read or write
|
||||
// anything beyond their own profile row.
|
||||
describe("HR-only access (is_hr_user gate)", () => {
|
||||
const createdUsers: TestUser[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
while (createdUsers.length) {
|
||||
const user = createdUsers.pop()!;
|
||||
await deleteTestUser(user);
|
||||
}
|
||||
});
|
||||
|
||||
it("a bare authenticated user (no profile row) reads zero employees, not an error", async () => {
|
||||
const user = await createBareAuthUser();
|
||||
createdUsers.push(user);
|
||||
const client = await signInAs(user);
|
||||
|
||||
const { data, error } = await client.from("employees").select("id");
|
||||
expect(error).toBeNull();
|
||||
expect(data).toEqual([]);
|
||||
});
|
||||
|
||||
it("a bare authenticated user cannot insert into org reference tables", async () => {
|
||||
const user = await createBareAuthUser();
|
||||
createdUsers.push(user);
|
||||
const client = await signInAs(user);
|
||||
|
||||
const { error } = await client.from("divisions").insert({ org_number: "20999999", name: `Test-${user.id}` });
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
|
||||
it("an inactive HR profile can read its own profile row", async () => {
|
||||
const user = await createHrUser({ active: false });
|
||||
createdUsers.push(user);
|
||||
const client = await signInAs(user);
|
||||
|
||||
const { data, error } = await client.from("profiles").select("id, is_active").eq("id", user.id);
|
||||
expect(error).toBeNull();
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data?.[0].is_active).toBe(false);
|
||||
});
|
||||
|
||||
it("an inactive HR profile reads zero employees and cannot call mutation RPCs", async () => {
|
||||
const user = await createHrUser({ active: false });
|
||||
createdUsers.push(user);
|
||||
const client = await signInAs(user);
|
||||
|
||||
const { data: employees, error: readError } = await client.from("employees").select("id");
|
||||
expect(readError).toBeNull();
|
||||
expect(employees).toEqual([]);
|
||||
|
||||
const { error: rpcError } = await client.rpc("hire_employee", {
|
||||
payload: { first_name: "X", last_name: "Y", gender: "m", birth_date: "1990-01-01", entry_date: "2020-01-01" },
|
||||
});
|
||||
expect(rpcError?.message).toMatch(/Nicht berechtigt/);
|
||||
});
|
||||
|
||||
it("an active HR user reads the seeded employee roster", async () => {
|
||||
const user = await createHrUser({ active: true });
|
||||
createdUsers.push(user);
|
||||
const client = await signInAs(user);
|
||||
|
||||
const { data, error } = await client.from("employees").select("id").limit(1);
|
||||
expect(error).toBeNull();
|
||||
expect((data ?? []).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("apply_due_pending_changes is revoked from authenticated users, even active HR", async () => {
|
||||
const user = await createHrUser({ active: true });
|
||||
createdUsers.push(user);
|
||||
const client = await signInAs(user);
|
||||
|
||||
const { error } = await client.rpc("apply_due_pending_changes");
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
|
||||
it("apply_due_pending_changes is callable by the service-role client", async () => {
|
||||
const { data, error } = await adminClient.rpc("apply_due_pending_changes");
|
||||
expect(error).toBeNull();
|
||||
expect(typeof data).toBe("number");
|
||||
});
|
||||
});
|
||||
123
tests/integration/data-integrity.test.ts
Normal file
123
tests/integration/data-integrity.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
adminClient,
|
||||
createHrUser,
|
||||
deleteTestEmployee,
|
||||
deleteTestUser,
|
||||
hireTestEmployee,
|
||||
isoDateOffset,
|
||||
pickSeededTeam,
|
||||
signInAs,
|
||||
type TestUser,
|
||||
} from "./helpers";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
// Two guards added by supabase/migrations/20260714120600_data_integrity_guards.sql
|
||||
// that previously had no enforcement anywhere: a Karenz return date may not
|
||||
// precede its own Karenz start, and a history entry may not predate the
|
||||
// employee's entry date.
|
||||
describe("data integrity guards", () => {
|
||||
let hrUser: TestUser;
|
||||
let hrClient: SupabaseClient<Database>;
|
||||
let teamA: { id: string };
|
||||
const employeeIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
teamA = await pickSeededTeam();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
async function freshEmployeeOnKarenz(): Promise<{ employeeId: string; karenzStartDate: string }> {
|
||||
const employeeId = await hireTestEmployee(hrClient, teamA.id);
|
||||
employeeIds.push(employeeId);
|
||||
const karenzStartDate = isoDateOffset(-5);
|
||||
const { error } = await hrClient.rpc("start_karenz", {
|
||||
payload: { employee_id: employeeId, karenz_start_date: karenzStartDate, planned_return_date: isoDateOffset(100) },
|
||||
});
|
||||
if (error) throw new Error(`start_karenz setup failed: ${error.message}`);
|
||||
return { employeeId, karenzStartDate };
|
||||
}
|
||||
|
||||
it("adjust_karenz_return rejects a return date on or before karenz_start_date", async () => {
|
||||
const { employeeId, karenzStartDate } = await freshEmployeeOnKarenz();
|
||||
|
||||
const { error } = await hrClient.rpc("adjust_karenz_return", {
|
||||
payload: { employee_id: employeeId, new_return_date: karenzStartDate },
|
||||
});
|
||||
expect(error?.message).toMatch(/Rückkehrdatum muss nach dem Karenzbeginn/);
|
||||
});
|
||||
|
||||
it("adjust_karenz_return accepts a return date after karenz_start_date", async () => {
|
||||
const { employeeId } = await freshEmployeeOnKarenz();
|
||||
const newReturnDate = isoDateOffset(50);
|
||||
|
||||
const { error } = await hrClient.rpc("adjust_karenz_return", {
|
||||
payload: { employee_id: employeeId, new_return_date: newReturnDate },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data: employee } = await adminClient.from("employees").select("karenz_return_date").eq("id", employeeId).single();
|
||||
expect(employee?.karenz_return_date).toBe(newReturnDate);
|
||||
});
|
||||
|
||||
it("record_karenz_return rejects a return date on or before karenz_start_date", async () => {
|
||||
const { employeeId, karenzStartDate } = await freshEmployeeOnKarenz();
|
||||
|
||||
const { error } = await hrClient.rpc("record_karenz_return", {
|
||||
payload: { employee_id: employeeId, return_date: karenzStartDate, employment_mode: "unverändert" },
|
||||
});
|
||||
expect(error?.message).toMatch(/Rückkehrdatum muss nach dem Karenzbeginn/);
|
||||
});
|
||||
|
||||
it("record_karenz_return with a valid date flips status back to Aktiv and clears karenz_start_date", async () => {
|
||||
const { employeeId } = await freshEmployeeOnKarenz();
|
||||
|
||||
const { error } = await hrClient.rpc("record_karenz_return", {
|
||||
payload: { employee_id: employeeId, return_date: isoDateOffset(0), employment_mode: "unverändert" },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data: employee } = await adminClient
|
||||
.from("employees")
|
||||
.select("status, karenz_start_date, karenz_return_date")
|
||||
.eq("id", employeeId)
|
||||
.single();
|
||||
expect(employee?.status).toBe("Aktiv");
|
||||
expect(employee?.karenz_start_date).toBeNull();
|
||||
expect(employee?.karenz_return_date).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects an employee_history row dated before the employee's entry_date", async () => {
|
||||
const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: isoDateOffset(-10) });
|
||||
employeeIds.push(employeeId);
|
||||
|
||||
const { error } = await adminClient.from("employee_history").insert({
|
||||
employee_id: employeeId,
|
||||
event_date: isoDateOffset(-11),
|
||||
event_type: "Vertragsänderung",
|
||||
description: "Sollte vom Trigger abgelehnt werden",
|
||||
});
|
||||
expect(error?.message).toMatch(/darf nicht vor dem Eintrittsdatum/);
|
||||
});
|
||||
|
||||
it("accepts an employee_history row dated exactly on the entry_date", async () => {
|
||||
const entryDate = isoDateOffset(-10);
|
||||
const employeeId = await hireTestEmployee(hrClient, teamA.id, { entry_date: entryDate });
|
||||
employeeIds.push(employeeId);
|
||||
|
||||
const { error } = await adminClient.from("employee_history").insert({
|
||||
employee_id: employeeId,
|
||||
event_date: entryDate,
|
||||
event_type: "Vertragsänderung",
|
||||
description: "Am Eintrittsdatum selbst, sollte erlaubt sein",
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
});
|
||||
163
tests/integration/effective-dating.test.ts
Normal file
163
tests/integration/effective-dating.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
adminClient,
|
||||
createHrUser,
|
||||
deleteTestEmployee,
|
||||
deleteTestUser,
|
||||
hireTestEmployee,
|
||||
isoDateOffset,
|
||||
pickSeededTeam,
|
||||
signInAs,
|
||||
teamLeadId,
|
||||
type TestUser,
|
||||
} from "./helpers";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
// Deferred/effective-dated changes (supabase/migrations/20260714120200_
|
||||
// effective_dating_rpcs.sql): a future "wirksam ab" date must queue a
|
||||
// pending_org_changes row instead of writing to `employees` immediately;
|
||||
// apply_due_pending_changes() applies it once due.
|
||||
describe("effective-dated mutations", () => {
|
||||
let hrUser: TestUser;
|
||||
let hrClient: SupabaseClient<Database>;
|
||||
let teamA: { id: string };
|
||||
let teamB: { id: string };
|
||||
const employeeIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
teamA = await pickSeededTeam();
|
||||
teamB = await pickSeededTeam(teamA.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
async function freshEmployee(teamId: string): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, teamId);
|
||||
employeeIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
it("transfer_employee with today's date writes immediately", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const newLead = await teamLeadId(teamB.id);
|
||||
|
||||
const { error } = await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(0), new_team_id: teamB.id },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
|
||||
expect(employee?.team_id).toBe(teamB.id);
|
||||
expect(employee?.manager_id).toBe(newLead);
|
||||
});
|
||||
|
||||
it("transfer_employee with a future date defers the write and applies it once due", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const newLead = await teamLeadId(teamB.id);
|
||||
|
||||
const { error } = await hrClient.rpc("transfer_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(30), new_team_id: teamB.id },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
// Not written yet — this is the exact bug the migration fixes: a
|
||||
// future-dated transfer must not overwrite the live record today.
|
||||
const { data: unchanged } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
|
||||
expect(unchanged?.team_id).toBe(teamA.id);
|
||||
|
||||
const { data: pending } = await adminClient
|
||||
.from("pending_org_changes")
|
||||
.select("id, status, payload")
|
||||
.eq("employee_id", employeeId)
|
||||
.eq("change_type", "transfer")
|
||||
.single();
|
||||
expect(pending?.status).toBe("pending");
|
||||
expect(pending?.payload.new_team_id).toBe(teamB.id);
|
||||
|
||||
// Fast-forward: simulate the effective date having arrived, then run
|
||||
// the same function the daily cron route calls.
|
||||
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
|
||||
const { data: appliedCount, error: applyError } = await adminClient.rpc("apply_due_pending_changes");
|
||||
expect(applyError).toBeNull();
|
||||
expect(appliedCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const { data: employee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
|
||||
expect(employee?.team_id).toBe(teamB.id);
|
||||
expect(employee?.manager_id).toBe(newLead);
|
||||
|
||||
const { data: appliedRow } = await adminClient
|
||||
.from("pending_org_changes")
|
||||
.select("status, applied_at")
|
||||
.eq("id", pending!.id)
|
||||
.single();
|
||||
expect(appliedRow?.status).toBe("applied");
|
||||
expect(appliedRow?.applied_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it("promote_employee with a future date does not change job_title/paygrade until applied", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
|
||||
const { error } = await hrClient.rpc("promote_employee", {
|
||||
payload: { employee_id: employeeId, effective_date: isoDateOffset(14), new_title: "Senior Testperson", new_paygrade: "D" },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data: unchanged } = await adminClient.from("employees").select("job_title, paygrade").eq("id", employeeId).single();
|
||||
expect(unchanged?.job_title).not.toBe("Senior Testperson");
|
||||
|
||||
const { data: pending } = await adminClient
|
||||
.from("pending_org_changes")
|
||||
.select("id")
|
||||
.eq("employee_id", employeeId)
|
||||
.eq("change_type", "promotion")
|
||||
.single();
|
||||
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
|
||||
await adminClient.rpc("apply_due_pending_changes");
|
||||
|
||||
const { data: employee } = await adminClient.from("employees").select("job_title, paygrade").eq("id", employeeId).single();
|
||||
expect(employee?.job_title).toBe("Senior Testperson");
|
||||
expect(employee?.paygrade).toBe("D");
|
||||
});
|
||||
|
||||
it("start_karenz with a future date sets karenz_start_date immediately but keeps status Aktiv", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const startDate = isoDateOffset(20);
|
||||
const returnDate = isoDateOffset(200);
|
||||
|
||||
const { error } = await hrClient.rpc("start_karenz", {
|
||||
payload: { employee_id: employeeId, karenz_start_date: startDate, planned_return_date: returnDate },
|
||||
});
|
||||
expect(error).toBeNull();
|
||||
|
||||
const { data: employee } = await adminClient
|
||||
.from("employees")
|
||||
.select("status, karenz_start_date")
|
||||
.eq("id", employeeId)
|
||||
.single();
|
||||
expect(employee?.status).toBe("Aktiv");
|
||||
expect(employee?.karenz_start_date).toBe(startDate);
|
||||
|
||||
const { data: pending } = await adminClient
|
||||
.from("pending_org_changes")
|
||||
.select("id")
|
||||
.eq("employee_id", employeeId)
|
||||
.eq("change_type", "karenz_start")
|
||||
.single();
|
||||
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
|
||||
await adminClient.rpc("apply_due_pending_changes");
|
||||
|
||||
const { data: afterApply } = await adminClient
|
||||
.from("employees")
|
||||
.select("status, karenz_return_date")
|
||||
.eq("id", employeeId)
|
||||
.single();
|
||||
expect(afterApply?.status).toBe("Karenz");
|
||||
expect(afterApply?.karenz_return_date).toBe(returnDate);
|
||||
});
|
||||
});
|
||||
146
tests/integration/helpers.ts
Normal file
146
tests/integration/helpers.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Database, EmploymentStatus } from "@/lib/supabase/types";
|
||||
|
||||
const URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
const SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!URL || !ANON_KEY || !SERVICE_ROLE_KEY) {
|
||||
throw new Error(
|
||||
"Missing Supabase env vars for integration tests. Start local Supabase (`npx supabase start`) and run " +
|
||||
"`npm run test:integration`, which loads .env.test.local automatically. See README.md."
|
||||
);
|
||||
}
|
||||
|
||||
export const adminClient: SupabaseClient<Database> = createClient(URL, SERVICE_ROLE_KEY, {
|
||||
auth: { autoRefreshToken: false, persistSession: false },
|
||||
});
|
||||
|
||||
export function anonClient(): SupabaseClient<Database> {
|
||||
return createClient(URL!, ANON_KEY!, { auth: { autoRefreshToken: false, persistSession: false } });
|
||||
}
|
||||
|
||||
export type TestUser = { id: string; email: string; password: string };
|
||||
|
||||
// Creates a real auth.users row (via the admin API) with no profiles row —
|
||||
// this is exactly the "signed up but never provisioned" state §2.3
|
||||
// describes: authenticated, but not HR.
|
||||
export async function createBareAuthUser(): Promise<TestUser> {
|
||||
const email = `test-${randomUUID()}@example.test`;
|
||||
const password = `Test-${randomUUID()}!`;
|
||||
const { data, error } = await adminClient.auth.admin.createUser({ email, password, email_confirm: true });
|
||||
if (error || !data.user) throw new Error(`createBareAuthUser failed: ${error?.message}`);
|
||||
return { id: data.user.id, email, password };
|
||||
}
|
||||
|
||||
export async function createHrUser(opts: { active: boolean }): Promise<TestUser> {
|
||||
const user = await createBareAuthUser();
|
||||
const { error } = await adminClient
|
||||
.from("profiles")
|
||||
.insert({ id: user.id, email: user.email, full_name: "Integrationstest HR", role: "hr", is_active: opts.active });
|
||||
if (error) throw new Error(`createHrUser profile insert failed: ${error.message}`);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function deleteTestUser(user: TestUser): Promise<void> {
|
||||
await adminClient.from("profiles").delete().eq("id", user.id);
|
||||
await adminClient.auth.admin.deleteUser(user.id);
|
||||
}
|
||||
|
||||
export async function signInAs(user: TestUser): Promise<SupabaseClient<Database>> {
|
||||
const client = anonClient();
|
||||
const { error } = await client.auth.signInWithPassword({ email: user.email, password: user.password });
|
||||
if (error) throw new Error(`signInAs(${user.email}) failed: ${error.message}`);
|
||||
return client;
|
||||
}
|
||||
|
||||
// Pulled from the seeded dataset (supabase/seed.ts) — any active, non-lead
|
||||
// employee works for read/mutation tests that don't care which one.
|
||||
export async function pickSeededEmployee(
|
||||
filter: Partial<{ status: EmploymentStatus; is_lead: boolean }> = {}
|
||||
): Promise<{
|
||||
id: string;
|
||||
team_id: string | null;
|
||||
division_id: string;
|
||||
manager_id: string | null;
|
||||
status: string;
|
||||
}> {
|
||||
let q = adminClient.from("employees").select("id, team_id, division_id, manager_id, status").limit(1);
|
||||
if (filter.status) q = q.eq("status", filter.status);
|
||||
if (filter.is_lead !== undefined) q = q.eq("is_lead", filter.is_lead);
|
||||
const { data, error } = await q.maybeSingle();
|
||||
if (error || !data) throw new Error(`pickSeededEmployee failed: ${error?.message ?? "no matching row"}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function pickSeededTeam(excludeTeamId?: string): Promise<{ id: string }> {
|
||||
const q = adminClient.from("teams").select("id").limit(2);
|
||||
const { data, error } = await q;
|
||||
if (error || !data?.length) throw new Error(`pickSeededTeam failed: ${error?.message}`);
|
||||
const match = data.find((t) => t.id !== excludeTeamId) ?? data[0];
|
||||
return match;
|
||||
}
|
||||
|
||||
export async function pickSeededLocation(): Promise<{ id: string }> {
|
||||
const { data, error } = await adminClient.from("locations").select("id").limit(1).maybeSingle();
|
||||
if (error || !data) throw new Error(`pickSeededLocation failed: ${error?.message ?? "no rows"}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// The seeded org guarantees exactly one active team lead per team (§2's
|
||||
// "reports-to" rule) — resolve_manager_for() relies on the same query.
|
||||
export async function teamLeadId(teamId: string): Promise<string | null> {
|
||||
const { data, error } = await adminClient
|
||||
.from("employees")
|
||||
.select("id")
|
||||
.eq("team_id", teamId)
|
||||
.eq("is_lead", true)
|
||||
.neq("status", "Ausgetreten")
|
||||
.maybeSingle();
|
||||
if (error) throw new Error(`teamLeadId(${teamId}) failed: ${error.message}`);
|
||||
return data?.id ?? null;
|
||||
}
|
||||
|
||||
// YYYY-MM-DD, offset from today — for building "wirksam ab" test payloads
|
||||
// without hardcoding dates that eventually go stale.
|
||||
export function isoDateOffset(days: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Hires a throwaway employee into `teamId` via the real hire_employee RPC
|
||||
// (not a raw insert) so every mutation test starts from a state the app
|
||||
// itself can produce. Caller must clean up with deleteTestEmployee.
|
||||
export async function hireTestEmployee(
|
||||
hrClient: SupabaseClient<Database>,
|
||||
teamId: string,
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Promise<string> {
|
||||
const location = await pickSeededLocation();
|
||||
const payload = {
|
||||
first_name: "Integrationstest",
|
||||
last_name: `Person-${randomUUID().slice(0, 8)}`,
|
||||
gender: "w",
|
||||
birth_date: "1990-01-01",
|
||||
location_id: location.id,
|
||||
team_id: teamId,
|
||||
job_title: "Integrationstest-Rolle",
|
||||
entry_date: isoDateOffset(-30),
|
||||
source: "Extern",
|
||||
...overrides,
|
||||
};
|
||||
const { data, error } = await hrClient.rpc("hire_employee", { payload });
|
||||
if (error || !data) throw new Error(`hireTestEmployee failed: ${error?.message ?? "no id returned"}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// employees has no cascade from audit_log (target_employee_id is a plain
|
||||
// FK, immutable log by design) — clear those rows first so the employee
|
||||
// delete itself doesn't fail with a foreign key violation. employee_history
|
||||
// and pending_org_changes do cascade on employee_id.
|
||||
export async function deleteTestEmployee(employeeId: string): Promise<void> {
|
||||
await adminClient.from("audit_log").delete().eq("target_employee_id", employeeId);
|
||||
await adminClient.from("employees").delete().eq("id", employeeId);
|
||||
}
|
||||
147
tests/integration/reorg.test.ts
Normal file
147
tests/integration/reorg.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
adminClient,
|
||||
createHrUser,
|
||||
deleteTestEmployee,
|
||||
deleteTestUser,
|
||||
hireTestEmployee,
|
||||
isoDateOffset,
|
||||
pickSeededTeam,
|
||||
signInAs,
|
||||
teamLeadId,
|
||||
type TestUser,
|
||||
} from "./helpers";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
// Reorg scenarios: immediate apply/undo must respect employee_history's
|
||||
// append-only contract (20260714120300_reorg_undo_append_only.sql), and a
|
||||
// future-dated scenario must defer every move via pending_org_changes until
|
||||
// its effective date, flipping reorg_scenarios.applied only once every move
|
||||
// has landed (20260714120200_effective_dating_rpcs.sql).
|
||||
describe("reorg scenarios", () => {
|
||||
let hrUser: TestUser;
|
||||
let hrClient: SupabaseClient<Database>;
|
||||
let teamA: { id: string };
|
||||
let teamB: { id: string };
|
||||
const employeeIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
hrUser = await createHrUser({ active: true });
|
||||
hrClient = await signInAs(hrUser);
|
||||
teamA = await pickSeededTeam();
|
||||
teamB = await pickSeededTeam(teamA.id);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of employeeIds) await deleteTestEmployee(id);
|
||||
await deleteTestUser(hrUser);
|
||||
});
|
||||
|
||||
async function freshEmployee(teamId: string): Promise<string> {
|
||||
const id = await hireTestEmployee(hrClient, teamId);
|
||||
employeeIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
it("applies an immediate reorg now, and undo appends a compensating history row instead of deleting", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const teamBLead = await teamLeadId(teamB.id);
|
||||
|
||||
const { data: scenarioId, error: applyError } = await hrClient.rpc("apply_reorg", {
|
||||
payload: {
|
||||
name: `Integrationstest Reorg ${employeeId.slice(0, 8)}`,
|
||||
effective_date: isoDateOffset(0),
|
||||
moves: [{ kind: "emp", label: "Test", employee_ids: [employeeId], target_team_id: teamB.id }],
|
||||
},
|
||||
});
|
||||
expect(applyError).toBeNull();
|
||||
expect(scenarioId).toBeTruthy();
|
||||
|
||||
const { data: movedEmployee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
|
||||
expect(movedEmployee?.team_id).toBe(teamB.id);
|
||||
expect(movedEmployee?.manager_id).toBe(teamBLead);
|
||||
|
||||
const { data: scenario } = await adminClient
|
||||
.from("reorg_scenarios")
|
||||
.select("applied, applied_at")
|
||||
.eq("id", scenarioId as string)
|
||||
.single();
|
||||
expect(scenario?.applied).toBe(true);
|
||||
expect(scenario?.applied_at).not.toBeNull();
|
||||
|
||||
const { count: historyBeforeUndo } = await adminClient
|
||||
.from("employee_history")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("employee_id", employeeId)
|
||||
.eq("event_type", "Reorganisation");
|
||||
expect(historyBeforeUndo).toBe(1);
|
||||
|
||||
const { error: undoError } = await hrClient.rpc("undo_reorg", { payload: { scenario_id: scenarioId } });
|
||||
expect(undoError).toBeNull();
|
||||
|
||||
const { data: revertedEmployee } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
|
||||
expect(revertedEmployee?.team_id).toBe(teamA.id);
|
||||
|
||||
const { data: undoneScenario } = await adminClient.from("reorg_scenarios").select("applied").eq("id", scenarioId as string).single();
|
||||
expect(undoneScenario?.applied).toBe(false);
|
||||
|
||||
// The original "Reorganisation" row must still be there — undo appends
|
||||
// a compensating entry, it never deletes (the bug the migration fixed).
|
||||
const { count: historyAfterUndo } = await adminClient
|
||||
.from("employee_history")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("employee_id", employeeId)
|
||||
.eq("event_type", "Reorganisation");
|
||||
expect(historyAfterUndo).toBe(2);
|
||||
});
|
||||
|
||||
it("defers a future-dated reorg and only flips reorg_scenarios.applied once its pending change lands", async () => {
|
||||
const employeeId = await freshEmployee(teamA.id);
|
||||
const teamBLead = await teamLeadId(teamB.id);
|
||||
|
||||
const { data: scenarioId, error: applyError } = await hrClient.rpc("apply_reorg", {
|
||||
payload: {
|
||||
name: `Integrationstest Reorg (zukünftig) ${employeeId.slice(0, 8)}`,
|
||||
effective_date: isoDateOffset(30),
|
||||
moves: [{ kind: "emp", label: "Test", employee_ids: [employeeId], target_team_id: teamB.id }],
|
||||
},
|
||||
});
|
||||
expect(applyError).toBeNull();
|
||||
|
||||
const { data: unchangedEmployee } = await adminClient.from("employees").select("team_id").eq("id", employeeId).single();
|
||||
expect(unchangedEmployee?.team_id).toBe(teamA.id);
|
||||
|
||||
const { data: scenarioBefore } = await adminClient
|
||||
.from("reorg_scenarios")
|
||||
.select("applied")
|
||||
.eq("id", scenarioId as string)
|
||||
.single();
|
||||
expect(scenarioBefore?.applied).toBe(false);
|
||||
|
||||
const { data: pending } = await adminClient
|
||||
.from("pending_org_changes")
|
||||
.select("id")
|
||||
.eq("employee_id", employeeId)
|
||||
.eq("reorg_scenario_id", scenarioId as string)
|
||||
.eq("status", "pending")
|
||||
.single();
|
||||
expect(pending).not.toBeNull();
|
||||
|
||||
await adminClient.from("pending_org_changes").update({ effective_date: isoDateOffset(0) }).eq("id", pending!.id);
|
||||
const { error: cronError } = await adminClient.rpc("apply_due_pending_changes");
|
||||
expect(cronError).toBeNull();
|
||||
|
||||
const { data: movedEmployee } = await adminClient.from("employees").select("team_id, manager_id").eq("id", employeeId).single();
|
||||
expect(movedEmployee?.team_id).toBe(teamB.id);
|
||||
expect(movedEmployee?.manager_id).toBe(teamBLead);
|
||||
|
||||
const { data: scenarioAfter } = await adminClient
|
||||
.from("reorg_scenarios")
|
||||
.select("applied, applied_at")
|
||||
.eq("id", scenarioId as string)
|
||||
.single();
|
||||
expect(scenarioAfter?.applied).toBe(true);
|
||||
expect(scenarioAfter?.applied_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
29
tests/unit/colors.test.ts
Normal file
29
tests/unit/colors.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { actionBadgeStyle, avatarColorFor, STATUS_STYLES } from "@/lib/colors";
|
||||
|
||||
describe("avatarColorFor", () => {
|
||||
it("is deterministic for the same seed", () => {
|
||||
expect(avatarColorFor("Maria Gruber")).toBe(avatarColorFor("Maria Gruber"));
|
||||
});
|
||||
|
||||
it("returns a value from the fixed palette (a hex color)", () => {
|
||||
expect(avatarColorFor("Anna Huber")).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("STATUS_STYLES", () => {
|
||||
it("has an entry for every employment status", () => {
|
||||
expect(Object.keys(STATUS_STYLES).sort()).toEqual(["Aktiv", "Ausgetreten", "Geplant", "Karenz"].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe("actionBadgeStyle", () => {
|
||||
it("maps known audit actions to their category style", () => {
|
||||
expect(actionBadgeStyle("Austritt")).toBe(STATUS_STYLES.Ausgetreten);
|
||||
expect(actionBadgeStyle("Neueinstellung")).toBe(STATUS_STYLES.Aktiv);
|
||||
});
|
||||
|
||||
it("falls back to the brand style for an unknown action", () => {
|
||||
expect(actionBadgeStyle("Irgendwas Unbekanntes")).toContain("bg-brand-100");
|
||||
});
|
||||
});
|
||||
78
tests/unit/format.test.ts
Normal file
78
tests/unit/format.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { daysBetween, fmtAge, fmtDate, initials, tenure } from "@/lib/format";
|
||||
|
||||
describe("fmtDate", () => {
|
||||
it("formats an ISO date string in de-AT order", () => {
|
||||
expect(fmtDate("2026-03-05")).toBe("05.03.2026");
|
||||
});
|
||||
|
||||
it("returns an em dash for null/undefined/empty input", () => {
|
||||
expect(fmtDate(null)).toBe("–");
|
||||
expect(fmtDate(undefined)).toBe("–");
|
||||
expect(fmtDate("")).toBe("–");
|
||||
});
|
||||
|
||||
it("returns an em dash for an invalid date string", () => {
|
||||
expect(fmtDate("not-a-date")).toBe("–");
|
||||
});
|
||||
});
|
||||
|
||||
describe("initials", () => {
|
||||
it("uppercases the first letter of each name", () => {
|
||||
expect(initials("maria", "gruber")).toBe("MG");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace before taking the first letter", () => {
|
||||
expect(initials(" Anna", "Huber ")).toBe("AH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fmtAge", () => {
|
||||
it("computes age correctly when the birthday has already passed this year", () => {
|
||||
const today = new Date();
|
||||
const birthDate = new Date(today.getFullYear() - 30, 0, 1); // Jan 1, definitely passed
|
||||
expect(fmtAge(birthDate)).toBe(30);
|
||||
});
|
||||
|
||||
it("subtracts one year when the birthday has not yet occurred this year", () => {
|
||||
const today = new Date();
|
||||
const future = new Date(today.getFullYear() - 30, 11, 31); // Dec 31, likely not yet passed
|
||||
if (today.getMonth() === 11 && today.getDate() === 31) return; // skip on the one day this'd be flaky
|
||||
expect(fmtAge(future)).toBe(29);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tenure", () => {
|
||||
it("formats whole years and months since entry", () => {
|
||||
const start = new Date(2020, 0, 15);
|
||||
const end = new Date(2023, 2, 15); // exactly 3 years, 2 months later
|
||||
expect(tenure(start, end)).toBe("3 Jahre, 2 Monate");
|
||||
});
|
||||
|
||||
it("uses singular Jahr/Monat for exactly 1", () => {
|
||||
const start = new Date(2020, 0, 1);
|
||||
const end = new Date(2021, 1, 1); // 1 year, 1 month
|
||||
expect(tenure(start, end)).toBe("1 Jahr, 1 Monat");
|
||||
});
|
||||
|
||||
it("falls back to 'unter 1 Monat' for less than a month", () => {
|
||||
const start = new Date(2024, 0, 1);
|
||||
const end = new Date(2024, 0, 15);
|
||||
expect(tenure(start, end)).toBe("unter 1 Monat");
|
||||
});
|
||||
|
||||
it("defaults the end date to today when no end date is given", () => {
|
||||
const start = new Date();
|
||||
expect(tenure(start)).toBe("unter 1 Monat");
|
||||
});
|
||||
});
|
||||
|
||||
describe("daysBetween", () => {
|
||||
it("computes whole days between two dates", () => {
|
||||
expect(daysBetween("2026-01-01", "2026-01-11")).toBe(10);
|
||||
});
|
||||
|
||||
it("returns a negative number when the second date precedes the first", () => {
|
||||
expect(daysBetween("2026-01-11", "2026-01-01")).toBe(-10);
|
||||
});
|
||||
});
|
||||
52
tests/unit/org.test.ts
Normal file
52
tests/unit/org.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { breadcrumbFor, breadcrumbLabel, type OrgMaps } from "@/lib/org";
|
||||
import type { Database } from "@/lib/supabase/types";
|
||||
|
||||
type Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||
|
||||
const division: Division = { id: "div-1", org_number: "20100000", name: "Produktion" };
|
||||
const department: Department = { id: "dept-1", org_number: "21100000", name: "Fertigung", division_id: "div-1" };
|
||||
const team: Team = { id: "team-1", org_number: "22010000", name: "Montage", department_id: "dept-1" };
|
||||
const location: Location = { id: "loc-1", name: "Wien-Hernals", country: "Österreich" };
|
||||
|
||||
const orgMaps: OrgMaps = {
|
||||
divisions: new Map([[division.id, division]]),
|
||||
departments: new Map([[department.id, department]]),
|
||||
teams: new Map([[team.id, team]]),
|
||||
locations: new Map([[location.id, location]]),
|
||||
divisionList: [division],
|
||||
locationList: [location],
|
||||
};
|
||||
|
||||
describe("breadcrumbFor", () => {
|
||||
it("resolves division, department (via team), and team from ids", () => {
|
||||
const result = breadcrumbFor(orgMaps, division.id, team.id);
|
||||
expect(result.division?.name).toBe("Produktion");
|
||||
expect(result.department?.name).toBe("Fertigung");
|
||||
expect(result.team?.name).toBe("Montage");
|
||||
});
|
||||
|
||||
it("has no team/department for a division head with no team (team_id null)", () => {
|
||||
const result = breadcrumbFor(orgMaps, division.id, null);
|
||||
expect(result.division?.name).toBe("Produktion");
|
||||
expect(result.team).toBeUndefined();
|
||||
expect(result.department).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("breadcrumbLabel", () => {
|
||||
it("joins division › department › team with the SAP-OM breadcrumb separator", () => {
|
||||
expect(breadcrumbLabel(orgMaps, division.id, team.id)).toBe("Produktion › Fertigung › Montage");
|
||||
});
|
||||
|
||||
it("omits missing segments instead of producing empty separators", () => {
|
||||
expect(breadcrumbLabel(orgMaps, division.id, null)).toBe("Produktion");
|
||||
});
|
||||
|
||||
it("falls back to a dash when nothing resolves", () => {
|
||||
expect(breadcrumbLabel(orgMaps, null, null)).toBe("–");
|
||||
});
|
||||
});
|
||||
164
tests/unit/reports.test.ts
Normal file
164
tests/unit/reports.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
aggregateReport,
|
||||
groupKeyFor,
|
||||
measureValue,
|
||||
MEASURE_LABELS,
|
||||
type Measure,
|
||||
type OrgLookups,
|
||||
type ReportEmployee,
|
||||
} from "@/lib/reports";
|
||||
|
||||
function emp(overrides: Partial<ReportEmployee> = {}): ReportEmployee {
|
||||
return {
|
||||
id: "1",
|
||||
first_name: "Maria",
|
||||
last_name: "Gruber",
|
||||
job_title: "Maschinenbediener:in",
|
||||
division_id: "div-1",
|
||||
team_id: "team-1",
|
||||
location_id: "loc-1",
|
||||
status: "Aktiv",
|
||||
employment_type: "Vollzeit",
|
||||
contract_type: "unbefristet",
|
||||
entry_date: "2020-01-01",
|
||||
exit_date: null,
|
||||
weekly_hours: 38.5,
|
||||
source: "Extern",
|
||||
paygrade: "B",
|
||||
birth_date: "1990-01-01",
|
||||
gender: "w",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const lookups: OrgLookups = {
|
||||
divisionName: new Map([["div-1", "Produktion"]]),
|
||||
departmentNameByTeam: new Map([["team-1", "Fertigung"]]),
|
||||
teamName: new Map([["team-1", "Montage"]]),
|
||||
locationName: new Map([["loc-1", "Wien-Hernals"]]),
|
||||
};
|
||||
|
||||
describe("no salary measure in scope (consolidation §4/§8)", () => {
|
||||
it("does not expose an avg_salary measure", () => {
|
||||
expect(Object.keys(MEASURE_LABELS)).not.toContain("avg_salary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupKeyFor", () => {
|
||||
it("resolves division/department/team/location names via lookups", () => {
|
||||
const e = emp();
|
||||
expect(groupKeyFor(e, "division", lookups)).toBe("Produktion");
|
||||
expect(groupKeyFor(e, "department", lookups)).toBe("Fertigung");
|
||||
expect(groupKeyFor(e, "team", lookups)).toBe("Montage");
|
||||
expect(groupKeyFor(e, "location", lookups)).toBe("Wien-Hernals");
|
||||
});
|
||||
|
||||
it("falls back to a dash for department/team when the employee has no team", () => {
|
||||
const e = emp({ team_id: null });
|
||||
expect(groupKeyFor(e, "department", lookups)).toBe("–");
|
||||
expect(groupKeyFor(e, "team", lookups)).toBe("–");
|
||||
});
|
||||
|
||||
it("falls back to 'Unbekannt' for an unresolvable division/location id", () => {
|
||||
const e = emp({ division_id: "ghost", location_id: "ghost" });
|
||||
expect(groupKeyFor(e, "division", lookups)).toBe("Unbekannt");
|
||||
expect(groupKeyFor(e, "location", lookups)).toBe("Unbekannt");
|
||||
});
|
||||
|
||||
it("derives entry_year from entry_date", () => {
|
||||
expect(groupKeyFor(emp({ entry_date: "2019-06-01" }), "entry_year", lookups)).toBe("2019");
|
||||
});
|
||||
});
|
||||
|
||||
describe("measureValue", () => {
|
||||
it("returns 0 for an empty row set regardless of measure", () => {
|
||||
expect(measureValue([], "headcount")).toBe(0);
|
||||
});
|
||||
|
||||
it("counts headcount/hires/exits as row length", () => {
|
||||
const rows = [emp(), emp({ id: "2" }), emp({ id: "3" })];
|
||||
(["headcount", "hires", "exits"] as Measure[]).forEach((m) => expect(measureValue(rows, m)).toBe(3));
|
||||
});
|
||||
|
||||
it("sums FTE as weekly_hours / 38.5", () => {
|
||||
const rows = [emp({ weekly_hours: 38.5 }), emp({ weekly_hours: 19.25 })];
|
||||
expect(measureValue(rows, "fte")).toBeCloseTo(1.5, 5);
|
||||
});
|
||||
|
||||
it("computes parttime_rate as a percentage of Teilzeit employees", () => {
|
||||
const rows = [
|
||||
emp({ employment_type: "Vollzeit" }),
|
||||
emp({ employment_type: "Teilzeit" }),
|
||||
emp({ employment_type: "Teilzeit" }),
|
||||
emp({ employment_type: "Teilzeit" }),
|
||||
];
|
||||
expect(measureValue(rows, "parttime_rate")).toBe(75);
|
||||
});
|
||||
|
||||
it("computes female_share as a percentage", () => {
|
||||
const rows = [emp({ gender: "w" }), emp({ gender: "w" }), emp({ gender: "m" })];
|
||||
expect(measureValue(rows, "female_share")).toBeCloseTo(66.67, 1);
|
||||
});
|
||||
|
||||
it("computes avg_age from birth_date", () => {
|
||||
const today = new Date();
|
||||
const thirtyYearsAgo = `${today.getFullYear() - 30}-01-01`;
|
||||
const fortyYearsAgo = `${today.getFullYear() - 40}-01-01`;
|
||||
const rows = [emp({ birth_date: thirtyYearsAgo }), emp({ birth_date: fortyYearsAgo })];
|
||||
expect(measureValue(rows, "avg_age")).toBeCloseTo(35, 0);
|
||||
});
|
||||
|
||||
it("computes avg_tenure in years using exit_date when present (deterministic)", () => {
|
||||
const rows = [
|
||||
emp({ entry_date: "2020-01-01", exit_date: "2022-01-01" }), // ~2 years
|
||||
emp({ entry_date: "2020-01-01", exit_date: "2024-01-01" }), // ~4 years
|
||||
];
|
||||
expect(measureValue(rows, "avg_tenure")).toBeCloseTo(3, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateReport", () => {
|
||||
it("groups rows, sorts groups descending by value, and never includes a salary field", () => {
|
||||
const employees = [
|
||||
emp({ id: "1", division_id: "div-1" }),
|
||||
emp({ id: "2", division_id: "div-1" }),
|
||||
emp({ id: "3", division_id: "div-2" }),
|
||||
];
|
||||
const multiDivisionLookups: OrgLookups = {
|
||||
...lookups,
|
||||
divisionName: new Map([
|
||||
["div-1", "Produktion"],
|
||||
["div-2", "IT"],
|
||||
]),
|
||||
};
|
||||
const rows = aggregateReport(employees, "headcount", "division", null, multiDivisionLookups);
|
||||
expect(rows[0]).toMatchObject({ key: "Produktion", value: 2, count: 2 });
|
||||
expect(rows[1]).toMatchObject({ key: "IT", value: 1, count: 1 });
|
||||
for (const row of rows) {
|
||||
for (const person of row.people) {
|
||||
expect(person).not.toHaveProperty("monthly_salary_gross");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("caps drilldown people list source data at the full group (UI caps display, not aggregation)", () => {
|
||||
const employees = Array.from({ length: 15 }, (_, i) => emp({ id: String(i) }));
|
||||
const rows = aggregateReport(employees, "headcount", "division", null, lookups);
|
||||
expect(rows[0].people).toHaveLength(15);
|
||||
});
|
||||
|
||||
it("adds a split breakdown per group when a split dimension is given", () => {
|
||||
const employees = [
|
||||
emp({ id: "1", employment_type: "Vollzeit" }),
|
||||
emp({ id: "2", employment_type: "Teilzeit" }),
|
||||
];
|
||||
const rows = aggregateReport(employees, "headcount", "division", "employment_type", lookups);
|
||||
expect(rows[0].split).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ key: "Vollzeit", value: 1, count: 1 },
|
||||
{ key: "Teilzeit", value: 1, count: 1 },
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
8
vercel.json
Normal file
8
vercel.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/cron/apply-pending-changes",
|
||||
"schedule": "0 3 * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
14
vitest.config.ts
Normal file
14
vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/unit/**/*.test.ts"],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
},
|
||||
},
|
||||
});
|
||||
22
vitest.integration.config.ts
Normal file
22
vitest.integration.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "node:path";
|
||||
|
||||
// Runs against a local Supabase instance (`npx supabase start`, then
|
||||
// `node --env-file=.env.test.local supabase/seed.ts` once) — see
|
||||
// docs/security.md and README.md "Tests" section for the full setup. These
|
||||
// tests exercise real RLS policies and RPC functions; they intentionally do
|
||||
// not run against the hosted project.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/integration/**/*.test.ts"],
|
||||
testTimeout: 20000,
|
||||
hookTimeout: 20000,
|
||||
fileParallelism: false,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "."),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user