Phase 2/3: Employees list/detail + mutation RPCs + action panels
- supabase/functions.sql, functions_2.sql: Postgres RPCs for every employee/position/reorg mutation (hire, terminate, transfer, promote, start/adjust/return karenz, change data, rehire, create position, staff internally, apply/undo reorg). Each resolves manager_id server-side, writes history + audit atomically, and enforces hr_admin via require_hr_admin() (backed by the existing RLS policy). - actions/employees.ts, positions.ts, reorg.ts: Server Actions wrapping the RPCs, returning success/error for client-side toast handling. - Employees list (search/filter/pagination) and detail (4 tabs: Stammdaten, Vertrag & Gehalt, Organisation, Historie) reading from employees_directory. - 6 action slide-over panels: Transfer, Promote, Karenz (start/adjust/ return), Daten aendern (person+contract diffing), Terminate (with direct- report reparenting warning + offboarding checklist), Rehire. - lib/org.ts: shared division/department/team/location lookups. Verified live: promote mutation updates salary, writes history/audit, and the detail page reflects it after refresh, no console errors. Note: the spec's Karenz-verwalten panel only covers employees already on Karenz; added a start-Karenz mode (Karenzbeginn/geplante Rueckkehr) to cover the Aktiv-employee case implied by the header button but not specified in the panel list.
This commit is contained in:
111
actions/employees.ts
Normal file
111
actions/employees.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import type { Database } from "@/lib/supabase/types";
|
||||||
|
|
||||||
|
type ActionResult = { success: boolean; error?: string };
|
||||||
|
type MutationFn = keyof Database["public"]["Functions"];
|
||||||
|
|
||||||
|
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.rpc(fn, { payload });
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
|
for (const path of revalidate) revalidatePath(path);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hireEmployee(payload: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
gender: "m" | "w";
|
||||||
|
birth_date: string;
|
||||||
|
sv_nummer?: string;
|
||||||
|
phone?: string;
|
||||||
|
position_id?: string;
|
||||||
|
team_id?: string;
|
||||||
|
job_title?: string;
|
||||||
|
location_id: string;
|
||||||
|
entry_date: string;
|
||||||
|
contract_type?: "unbefristet" | "befristet";
|
||||||
|
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 }> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase.rpc("hire_employee", { payload });
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
|
revalidatePath("/employees");
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/positions");
|
||||||
|
return { success: true, employeeId: data as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function terminateEmployee(payload: {
|
||||||
|
employee_id: string;
|
||||||
|
exit_date: string;
|
||||||
|
exit_reason: string;
|
||||||
|
note?: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
return callRpc("terminate_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transferEmployee(payload: {
|
||||||
|
employee_id: string;
|
||||||
|
effective_date: string;
|
||||||
|
new_team_id: string;
|
||||||
|
new_title?: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
return callRpc("transfer_employee", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startKarenz(payload: {
|
||||||
|
employee_id: string;
|
||||||
|
karenz_start_date: string;
|
||||||
|
planned_return_date: string;
|
||||||
|
note?: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
return callRpc("start_karenz", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adjustKarenzReturn(payload: {
|
||||||
|
employee_id: string;
|
||||||
|
new_return_date: string;
|
||||||
|
note?: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
return callRpc("adjust_karenz_return", payload, [`/employees/${payload.employee_id}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordKarenzReturn(payload: {
|
||||||
|
employee_id: string;
|
||||||
|
return_date: string;
|
||||||
|
employment_mode: "unverändert" | "Vollzeit" | "Teilzeit";
|
||||||
|
weekly_hours?: number;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
return callRpc("record_karenz_return", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeEmployeeData(payload: {
|
||||||
|
employee_id: string;
|
||||||
|
person: Record<string, unknown>;
|
||||||
|
contract: Record<string, unknown>;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
return callRpc("change_employee_data", payload, [`/employees/${payload.employee_id}`, "/employees"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rehireEmployee(payload: { employee_id: string; rehire_date: string }): Promise<ActionResult> {
|
||||||
|
return callRpc("rehire_employee", payload, [`/employees/${payload.employee_id}`, "/employees", "/"]);
|
||||||
|
}
|
||||||
36
actions/positions.ts
Normal file
36
actions/positions.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
type ActionResult = { success: boolean; error?: string };
|
||||||
|
|
||||||
|
export async function createPosition(payload: {
|
||||||
|
title: string;
|
||||||
|
superior_employee_id: string;
|
||||||
|
is_lead: boolean;
|
||||||
|
team_id?: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.rpc("create_position", { payload });
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
|
revalidatePath("/positions");
|
||||||
|
revalidatePath("/orgchart");
|
||||||
|
revalidatePath("/");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function staffPositionInternally(payload: {
|
||||||
|
position_id: string;
|
||||||
|
employee_id: string;
|
||||||
|
}): Promise<ActionResult> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.rpc("staff_position_internally", { payload });
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
|
revalidatePath("/positions");
|
||||||
|
revalidatePath("/orgchart");
|
||||||
|
revalidatePath("/employees");
|
||||||
|
revalidatePath(`/employees/${payload.employee_id}`);
|
||||||
|
revalidatePath("/");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
37
actions/reorg.ts
Normal file
37
actions/reorg.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
type ActionResult = { success: boolean; error?: string };
|
||||||
|
|
||||||
|
export type ReorgMovePayload = {
|
||||||
|
kind: "emp" | "team" | "abt" | "dept";
|
||||||
|
label: string;
|
||||||
|
employee_ids: string[];
|
||||||
|
target_team_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function applyReorg(payload: {
|
||||||
|
name: string;
|
||||||
|
effective_date: string;
|
||||||
|
moves: ReorgMovePayload[];
|
||||||
|
}): Promise<ActionResult & { scenarioId?: string }> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase.rpc("apply_reorg", { payload });
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
|
revalidatePath("/orgchart");
|
||||||
|
revalidatePath("/employees");
|
||||||
|
revalidatePath("/");
|
||||||
|
return { success: true, scenarioId: data as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function undoReorg(payload: { scenario_id: string }): Promise<ActionResult> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { error } = await supabase.rpc("undo_reorg", { payload });
|
||||||
|
if (error) return { success: false, error: error.message };
|
||||||
|
revalidatePath("/orgchart");
|
||||||
|
revalidatePath("/employees");
|
||||||
|
revalidatePath("/");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
52
app/(app)/employees/[id]/page.tsx
Normal file
52
app/(app)/employees/[id]/page.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
type PageProps = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
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();
|
||||||
|
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()
|
||||||
|
: Promise.resolve({ data: null }),
|
||||||
|
supabase
|
||||||
|
.from("employees_directory")
|
||||||
|
.select("id, first_name, last_name, job_title, status")
|
||||||
|
.eq("manager_id", id)
|
||||||
|
.order("last_name"),
|
||||||
|
supabase.from("employee_history").select("*").eq("employee_id", id).order("event_date", { ascending: false }).order("created_at", { ascending: false }),
|
||||||
|
supabase.from("divisions").select("*").order("name"),
|
||||||
|
supabase.from("departments").select("*"),
|
||||||
|
supabase.from("teams").select("*"),
|
||||||
|
supabase.from("locations").select("*").order("name"),
|
||||||
|
supabase.from("positions").select("id, position_number, title, team_id, is_lead").eq("status", "open"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmployeeDetail
|
||||||
|
employee={employee}
|
||||||
|
manager={manager ?? null}
|
||||||
|
directReports={directReports ?? []}
|
||||||
|
history={history ?? []}
|
||||||
|
divisions={divisions ?? []}
|
||||||
|
departments={departments ?? []}
|
||||||
|
teams={teams ?? []}
|
||||||
|
locations={locations ?? []}
|
||||||
|
openPositions={openPositions ?? []}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
app/(app)/employees/page.tsx
Normal file
142
app/(app)/employees/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { EmployeeFilters } from "@/components/employees/EmployeeFilters";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { StatusChip } from "@/components/ui/StatusChip";
|
||||||
|
import { fmtDate } from "@/lib/format";
|
||||||
|
import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 15;
|
||||||
|
|
||||||
|
type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string };
|
||||||
|
|
||||||
|
type EmployeesPageProps = {
|
||||||
|
searchParams: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function pageHref(params: SearchParams, page: number): string {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params.q) sp.set("q", params.q);
|
||||||
|
if (params.division) sp.set("division", params.division);
|
||||||
|
if (params.status) sp.set("status", params.status);
|
||||||
|
if (params.location) sp.set("location", params.location);
|
||||||
|
sp.set("page", String(page));
|
||||||
|
return `/employees?${sp.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
||||||
|
const params = await searchParams;
|
||||||
|
const supabase = await createClient();
|
||||||
|
const orgMaps = await loadOrgMaps(supabase);
|
||||||
|
|
||||||
|
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||||||
|
const from = (page - 1) * PAGE_SIZE;
|
||||||
|
const to = from + PAGE_SIZE - 1;
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.from("employees_directory")
|
||||||
|
.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" }
|
||||||
|
)
|
||||||
|
.order("last_name", { ascending: true })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (params.q) {
|
||||||
|
const q = params.q.trim();
|
||||||
|
if (/^\d+$/.test(q)) {
|
||||||
|
query = query.eq("personnel_number", Number(q));
|
||||||
|
} else {
|
||||||
|
query = query.or(`first_name.ilike.%${q}%,last_name.ilike.%${q}%,job_title.ilike.%${q}%`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (params.division) query = query.eq("division_id", params.division);
|
||||||
|
if (params.status) query = query.eq("status", params.status as EmploymentStatus);
|
||||||
|
if (params.location) query = query.eq("location_id", params.location);
|
||||||
|
|
||||||
|
const { data: employeesData, count } = await query;
|
||||||
|
const employees = employeesData ?? [];
|
||||||
|
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Suspense>
|
||||||
|
<EmployeeFilters divisions={orgMaps.divisionList} locations={orgMaps.locationList} />
|
||||||
|
</Suspense>
|
||||||
|
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded border border-border bg-white">
|
||||||
|
<table className="w-full min-w-[800px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-surface text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||||
|
<th className="px-4 py-3">Mitarbeiter:in</th>
|
||||||
|
<th className="px-4 py-3">Pers.-Nr.</th>
|
||||||
|
<th className="px-4 py-3">Bereich/Team</th>
|
||||||
|
<th className="px-4 py-3">Standort</th>
|
||||||
|
<th className="px-4 py-3">Eintritt</th>
|
||||||
|
<th className="px-4 py-3">Beschäftigung</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{employees.map((e) => {
|
||||||
|
const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
|
||||||
|
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
|
||||||
|
return (
|
||||||
|
<tr key={e.id} className="border-b border-border last:border-0 hover:bg-surface">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/employees/${e.id}`} className="flex items-center gap-3">
|
||||||
|
<Avatar firstName={e.first_name} lastName={e.last_name} />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-ink">
|
||||||
|
{e.first_name} {e.last_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-muted">{e.job_title}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-ink-body">{e.personnel_number}</td>
|
||||||
|
<td className="px-4 py-3 text-ink-body">
|
||||||
|
<div>{division?.name ?? "–"}</div>
|
||||||
|
<div className="text-xs text-ink-muted">{team?.name ?? "–"}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-ink-body">{location?.name ?? "–"}</td>
|
||||||
|
<td className="px-4 py-3 text-ink-body">{fmtDate(e.entry_date)}</td>
|
||||||
|
<td className="px-4 py-3 text-ink-body">
|
||||||
|
{e.employment_type} · {e.weekly_hours}h
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StatusChip status={e.status} entryDate={e.entry_date} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{employees.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7} className="px-4 py-8 text-center text-sm text-ink-muted">
|
||||||
|
Keine Mitarbeiter:innen gefunden.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 text-sm">
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p}
|
||||||
|
href={pageHref(params, p)}
|
||||||
|
className={`rounded px-3 py-1 ${p === page ? "bg-brand-500 text-white" : "text-ink-body hover:bg-surface"}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
components/employees/EmployeeDetail.tsx
Normal file
158
components/employees/EmployeeDetail.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowRightLeft, Clock, Pencil, RotateCcw, TrendingUp, XCircle } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { StatusChip } from "@/components/ui/StatusChip";
|
||||||
|
import { tenure } from "@/lib/format";
|
||||||
|
import type { Database } from "@/lib/supabase/types";
|
||||||
|
import { DatenAendernPanel } from "./panels/DatenAendernPanel";
|
||||||
|
import { KarenzPanel } from "./panels/KarenzPanel";
|
||||||
|
import { PromotePanel } from "./panels/PromotePanel";
|
||||||
|
import { RehirePanel } from "./panels/RehirePanel";
|
||||||
|
import { TerminatePanel } from "./panels/TerminatePanel";
|
||||||
|
import { TransferPanel } from "./panels/TransferPanel";
|
||||||
|
import { HistorieTab } from "./tabs/HistorieTab";
|
||||||
|
import { OrganisationTab } from "./tabs/OrganisationTab";
|
||||||
|
import { StammdatenTab } from "./tabs/StammdatenTab";
|
||||||
|
import { VertragTab } from "./tabs/VertragTab";
|
||||||
|
|
||||||
|
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||||
|
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"];
|
||||||
|
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||||
|
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
|
||||||
|
type OpenPosition = { id: string; position_number: string; title: string; team_id: string; is_lead: boolean };
|
||||||
|
|
||||||
|
type EmployeeDetailProps = {
|
||||||
|
employee: EmployeeRow;
|
||||||
|
manager: MiniEmployee | null;
|
||||||
|
directReports: MiniEmployee[];
|
||||||
|
history: HistoryRow[];
|
||||||
|
divisions: Division[];
|
||||||
|
departments: Department[];
|
||||||
|
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;
|
||||||
|
|
||||||
|
export function EmployeeDetail(props: EmployeeDetailProps) {
|
||||||
|
const { employee, manager, directReports, history, divisions, departments, teams, locations, canEdit } = props;
|
||||||
|
const [tab, setTab] = useState<(typeof TABS)[number]>("Stammdaten");
|
||||||
|
const [panel, setPanel] = useState<PanelType>(null);
|
||||||
|
|
||||||
|
const division = divisions.find((d) => d.id === employee.division_id);
|
||||||
|
const team = employee.team_id ? teams.find((t) => t.id === employee.team_id) : undefined;
|
||||||
|
const department = team ? departments.find((d) => d.id === team.department_id) : undefined;
|
||||||
|
const breadcrumb = [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–";
|
||||||
|
const location = locations.find((l) => l.id === employee.location_id);
|
||||||
|
|
||||||
|
const isActive = employee.status === "Aktiv" || employee.status === "Karenz";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="rounded border border-border bg-white p-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Avatar firstName={employee.first_name} lastName={employee.last_name} size="lg" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-extrabold text-ink">
|
||||||
|
{employee.first_name} {employee.last_name}
|
||||||
|
</h2>
|
||||||
|
<StatusChip status={employee.status} entryDate={employee.entry_date} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-ink-body">{employee.job_title}</p>
|
||||||
|
<p className="text-xs text-ink-muted">{breadcrumb}</p>
|
||||||
|
<p className="mt-1 text-xs text-ink-muted">
|
||||||
|
Pers.-Nr. {employee.personnel_number}
|
||||||
|
{employee.status !== "Geplant" && <> · Zugehörigkeit: {tenure(employee.entry_date, employee.exit_date)}</>}
|
||||||
|
</p>
|
||||||
|
</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" && (
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="flex gap-1 border-b border-border">
|
||||||
|
{TABS.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className={`border-b-2 px-4 py-2 text-sm font-semibold ${
|
||||||
|
tab === t ? "border-brand-500 text-brand-700" : "border-transparent text-ink-muted hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded border border-border bg-white p-6">
|
||||||
|
{tab === "Stammdaten" && <StammdatenTab employee={employee} location={location} />}
|
||||||
|
{tab === "Vertrag & Gehalt" && <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} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({ icon: Icon, label, onClick }: { icon: typeof ArrowRightLeft; label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-sm font-semibold text-ink-body hover:bg-surface">
|
||||||
|
<Icon className="h-4 w-4" /> {label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
components/employees/EmployeeFilters.tsx
Normal file
90
components/employees/EmployeeFilters.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type EmployeeFiltersProps = {
|
||||||
|
divisions: { id: string; name: string }[];
|
||||||
|
locations: { id: string; name: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const;
|
||||||
|
|
||||||
|
export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const [q, setQ] = useState(searchParams.get("q") ?? "");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(() => {
|
||||||
|
const current = new URLSearchParams(searchParams.toString());
|
||||||
|
if (q) current.set("q", q);
|
||||||
|
else current.delete("q");
|
||||||
|
current.delete("page");
|
||||||
|
const next = current.toString();
|
||||||
|
if (next !== searchParams.toString()) router.push(`${pathname}?${next}`);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
function updateParam(key: string, value: string) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value) params.set(key, value);
|
||||||
|
else params.delete(key);
|
||||||
|
params.delete("page");
|
||||||
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="flex min-w-[240px] flex-1 items-center gap-2 rounded border border-border bg-white px-3 py-2">
|
||||||
|
<Search className="h-4 w-4 shrink-0 text-ink-muted" />
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Name, Pers.-Nr., Titel…"
|
||||||
|
className="w-full text-sm text-ink outline-none placeholder:text-ink-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
defaultValue={searchParams.get("division") ?? ""}
|
||||||
|
onChange={(e) => updateParam("division", e.target.value)}
|
||||||
|
className="rounded border border-border bg-white px-3 py-2 text-sm text-ink"
|
||||||
|
>
|
||||||
|
<option value="">Alle Bereiche</option>
|
||||||
|
{divisions.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
defaultValue={searchParams.get("status") ?? ""}
|
||||||
|
onChange={(e) => updateParam("status", e.target.value)}
|
||||||
|
className="rounded border border-border bg-white px-3 py-2 text-sm text-ink"
|
||||||
|
>
|
||||||
|
<option value="">Alle Status</option>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
defaultValue={searchParams.get("location") ?? ""}
|
||||||
|
onChange={(e) => updateParam("location", e.target.value)}
|
||||||
|
className="rounded border border-border bg-white px-3 py-2 text-sm text-ink"
|
||||||
|
>
|
||||||
|
<option value="">Alle Standorte</option>
|
||||||
|
{locations.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
{l.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
components/employees/panels/DatenAendernPanel.tsx
Normal file
228
components/employees/panels/DatenAendernPanel.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { changeEmployeeData } from "@/actions/employees";
|
||||||
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
|
import { useToast } from "@/components/ui/Toast";
|
||||||
|
import type { ContractType, Database, EmploymentType, GenderType } from "@/lib/supabase/types";
|
||||||
|
|
||||||
|
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||||
|
|
||||||
|
const NATIONALITIES = ["Österreich", "Deutschland", "Tschechien", "Slowenien", "Türkei", "Serbien", "Kroatien", "Bosnien", "Ungarn", "Andere"];
|
||||||
|
const ADDRESS_COUNTRIES = ["Österreich", "Deutschland", "Tschechien", "Slowenien", "Andere"];
|
||||||
|
|
||||||
|
export function DatenAendernPanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const [firstName, setFirstName] = useState(employee.first_name);
|
||||||
|
const [lastName, setLastName] = useState(employee.last_name);
|
||||||
|
const [gender, setGender] = useState<GenderType>(employee.gender);
|
||||||
|
const [birthDate, setBirthDate] = useState(employee.birth_date);
|
||||||
|
const [svNummer, setSvNummer] = useState(employee.sv_nummer ?? "");
|
||||||
|
const [nationality, setNationality] = useState(employee.nationality);
|
||||||
|
const [address, setAddress] = useState(employee.address ?? "");
|
||||||
|
const [addressCountry, setAddressCountry] = useState(employee.address_country ?? "Österreich");
|
||||||
|
const [email, setEmail] = useState(employee.email);
|
||||||
|
const [phone, setPhone] = useState(employee.phone ?? "");
|
||||||
|
|
||||||
|
const [employmentType, setEmploymentType] = useState<EmploymentType>(employee.employment_type);
|
||||||
|
const [weeklyHours, setWeeklyHours] = useState(String(employee.weekly_hours));
|
||||||
|
const [contractType, setContractType] = useState<ContractType>(employee.contract_type);
|
||||||
|
const [contractEndDate, setContractEndDate] = useState(employee.contract_end_date ?? "");
|
||||||
|
|
||||||
|
function handleEmploymentTypeChange(value: EmploymentType) {
|
||||||
|
setEmploymentType(value);
|
||||||
|
if (value === "Vollzeit") setWeeklyHours("38.5");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (employmentType === "Teilzeit" && (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5)) {
|
||||||
|
showToast("Wochenstunden bei Teilzeit müssen zwischen 0 und 38,5 liegen.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (contractType === "befristet" && !contractEndDate) {
|
||||||
|
showToast("Bei befristetem Vertrag ist ein Enddatum erforderlich.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
const result = await changeEmployeeData({
|
||||||
|
employee_id: employee.id,
|
||||||
|
person: {
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
gender,
|
||||||
|
birth_date: birthDate,
|
||||||
|
sv_nummer: svNummer,
|
||||||
|
nationality,
|
||||||
|
address,
|
||||||
|
address_country: addressCountry,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
},
|
||||||
|
contract: {
|
||||||
|
employment_type: employmentType,
|
||||||
|
weekly_hours: Number(weeklyHours),
|
||||||
|
contract_type: contractType,
|
||||||
|
contract_end_date: contractType === "befristet" ? contractEndDate : "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setPending(false);
|
||||||
|
if (result.success) {
|
||||||
|
showToast("Daten aktualisiert.");
|
||||||
|
router.refresh();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideOver
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Daten ändern"
|
||||||
|
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-bold text-ink">Person</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">Vorname</label>
|
||||||
|
<input value={firstName} onChange={(e) => setFirstName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">Nachname</label>
|
||||||
|
<input value={lastName} onChange={(e) => setLastName(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">Geschlecht</label>
|
||||||
|
<select value={gender} onChange={(e) => setGender(e.target.value as GenderType)} className="w-full rounded border border-border px-3 py-2 text-sm">
|
||||||
|
<option value="m">männlich</option>
|
||||||
|
<option value="w">weiblich</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">Geburtsdatum</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={birthDate}
|
||||||
|
onChange={(e) => setBirthDate(e.target.value)}
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">SV-Nummer</label>
|
||||||
|
<input value={svNummer} onChange={(e) => setSvNummer(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">Staatsbürgerschaft</label>
|
||||||
|
<select value={nationality} onChange={(e) => setNationality(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm">
|
||||||
|
{NATIONALITIES.map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">Adresse</label>
|
||||||
|
<input value={address} onChange={(e) => setAddress(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">Land</label>
|
||||||
|
<select value={addressCountry} onChange={(e) => setAddressCountry(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm">
|
||||||
|
{ADDRESS_COUNTRIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">E-Mail</label>
|
||||||
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">Telefon</label>
|
||||||
|
<input value={phone} onChange={(e) => setPhone(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-bold text-ink">Vertrag</h3>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">Beschäftigungsausmaß</label>
|
||||||
|
<select
|
||||||
|
value={employmentType}
|
||||||
|
onChange={(e) => handleEmploymentTypeChange(e.target.value as EmploymentType)}
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="Vollzeit">Vollzeit</option>
|
||||||
|
<option value="Teilzeit">Teilzeit</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">Wochenstunden</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
value={weeklyHours}
|
||||||
|
disabled={employmentType === "Vollzeit"}
|
||||||
|
onChange={(e) => setWeeklyHours(e.target.value)}
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm disabled:bg-surface disabled:text-ink-muted"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">Vertragsart</label>
|
||||||
|
<select
|
||||||
|
value={contractType}
|
||||||
|
onChange={(e) => setContractType(e.target.value as ContractType)}
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="unbefristet">unbefristet</option>
|
||||||
|
<option value="befristet">befristet</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{contractType === "befristet" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-semibold text-ink-muted">Befristet bis*</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={contractEndDate}
|
||||||
|
onChange={(e) => setContractEndDate(e.target.value)}
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SlideOver>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
components/employees/panels/KarenzPanel.tsx
Normal file
221
components/employees/panels/KarenzPanel.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { adjustKarenzReturn, recordKarenzReturn, startKarenz } from "@/actions/employees";
|
||||||
|
import { SegmentedControl } from "@/components/ui/SegmentedControl";
|
||||||
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
|
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 Mode = "adjust" | "return";
|
||||||
|
type EmploymentMode = "unverändert" | "Vollzeit" | "Teilzeit";
|
||||||
|
|
||||||
|
export function KarenzPanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const isOnKarenz = employee.status === "Karenz";
|
||||||
|
const [mode, setMode] = useState<Mode>("adjust");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const [karenzStart, setKarenzStart] = useState("");
|
||||||
|
const [plannedReturn, setPlannedReturn] = useState("");
|
||||||
|
const [startNote, setStartNote] = useState("");
|
||||||
|
|
||||||
|
const [newReturnDate, setNewReturnDate] = useState(employee.karenz_return_date ?? "");
|
||||||
|
const [adjustNote, setAdjustNote] = useState("");
|
||||||
|
|
||||||
|
const [returnDate, setReturnDate] = useState("");
|
||||||
|
const [employmentMode, setEmploymentMode] = useState<EmploymentMode>("unverändert");
|
||||||
|
const [weeklyHours, setWeeklyHours] = useState("20");
|
||||||
|
|
||||||
|
const diffDays =
|
||||||
|
isOnKarenz && employee.karenz_return_date && newReturnDate
|
||||||
|
? Math.round((new Date(newReturnDate).getTime() - new Date(employee.karenz_return_date).getTime()) / 86400000)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
async function handleStart() {
|
||||||
|
if (!karenzStart || !plannedReturn) {
|
||||||
|
showToast("Bitte beide Daten angeben.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
const result = await startKarenz({ employee_id: employee.id, karenz_start_date: karenzStart, planned_return_date: plannedReturn, note: startNote });
|
||||||
|
setPending(false);
|
||||||
|
if (result.success) {
|
||||||
|
showToast("Karenz erfasst.");
|
||||||
|
router.refresh();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
showToast(result.error ?? "Fehler.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdjust() {
|
||||||
|
if (!newReturnDate || newReturnDate === employee.karenz_return_date) {
|
||||||
|
showToast("Bitte ein neues Rückkehrdatum angeben.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (new Date(newReturnDate) <= new Date()) {
|
||||||
|
showToast("Rückkehrdatum muss in der Zukunft liegen.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
const result = await adjustKarenzReturn({ employee_id: employee.id, new_return_date: newReturnDate, note: adjustNote });
|
||||||
|
setPending(false);
|
||||||
|
if (result.success) {
|
||||||
|
showToast("Rückkehrdatum angepasst.");
|
||||||
|
router.refresh();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
showToast(result.error ?? "Fehler.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReturn() {
|
||||||
|
if (!returnDate) {
|
||||||
|
showToast("Bitte Rückkehrdatum angeben.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (employmentMode === "Teilzeit" && (Number(weeklyHours) <= 0 || Number(weeklyHours) >= 38.5)) {
|
||||||
|
showToast("Wochenstunden müssen zwischen 0 und 38,5 liegen.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
const result = await recordKarenzReturn({
|
||||||
|
employee_id: employee.id,
|
||||||
|
return_date: returnDate,
|
||||||
|
employment_mode: employmentMode,
|
||||||
|
weekly_hours: employmentMode === "Teilzeit" ? Number(weeklyHours) : undefined,
|
||||||
|
});
|
||||||
|
setPending(false);
|
||||||
|
if (result.success) {
|
||||||
|
showToast("Wiedereintritt erfasst.");
|
||||||
|
router.refresh();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
showToast(result.error ?? "Fehler.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideOver
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={isOnKarenz ? "Karenz verwalten" : "Karenz"}
|
||||||
|
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
{!isOnKarenz && (
|
||||||
|
<button onClick={handleStart} disabled={pending} className="rounded bg-warning-text px-4 py-2 text-sm font-semibold text-white disabled:opacity-50">
|
||||||
|
Karenz erfassen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isOnKarenz && mode === "adjust" && (
|
||||||
|
<button onClick={handleAdjust} disabled={pending} className="rounded bg-warning-text px-4 py-2 text-sm font-semibold text-white disabled:opacity-50">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isOnKarenz && mode === "return" && (
|
||||||
|
<button onClick={handleReturn} disabled={pending} className="rounded bg-warning-text px-4 py-2 text-sm font-semibold text-white disabled:opacity-50">
|
||||||
|
Wiedereintritt erfassen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!isOnKarenz && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-ink">Karenzbeginn*</label>
|
||||||
|
<input type="date" value={karenzStart} onChange={(e) => setKarenzStart(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">Geplante Rückkehr*</label>
|
||||||
|
<input type="date" value={plannedReturn} onChange={(e) => setPlannedReturn(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">Anmerkung</label>
|
||||||
|
<textarea value={startNote} onChange={(e) => setStartNote(e.target.value)} rows={3} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOnKarenz && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SegmentedControl<Mode>
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
options={[
|
||||||
|
{ value: "adjust", label: "Rückkehr anpassen" },
|
||||||
|
{ value: "return", label: "Wiedereintritt erfassen" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === "adjust" && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-ink-muted">Aktuelles Rückkehrdatum: {fmtDate(employee.karenz_return_date)}</p>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-ink">Neues Rückkehrdatum*</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={newReturnDate}
|
||||||
|
onChange={(e) => setNewReturnDate(e.target.value)}
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{diffDays !== 0 && (
|
||||||
|
<div className={`rounded px-3 py-2 text-sm ${diffDays > 0 ? "bg-warning-bg text-warning-text" : "bg-success-bg text-success-text"}`}>
|
||||||
|
{diffDays > 0 ? `Verlängerung um ${diffDays} Tage` : `Verkürzung um ${Math.abs(diffDays)} Tage`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-ink">Grund/Anmerkung</label>
|
||||||
|
<textarea value={adjustNote} onChange={(e) => setAdjustNote(e.target.value)} rows={3} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mode === "return" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-ink">Rückkehrdatum*</label>
|
||||||
|
<input type="date" value={returnDate} onChange={(e) => setReturnDate(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">Beschäftigungsausmaß</label>
|
||||||
|
<select
|
||||||
|
value={employmentMode}
|
||||||
|
onChange={(e) => setEmploymentMode(e.target.value as EmploymentMode)}
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="unverändert">unverändert</option>
|
||||||
|
<option value="Vollzeit">Vollzeit (38,5h)</option>
|
||||||
|
<option value="Teilzeit">Teilzeit-Elternteilzeit</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{employmentMode === "Teilzeit" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-ink">Wochenstunden (unter 38,5)*</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
max="38"
|
||||||
|
value={weeklyHours}
|
||||||
|
onChange={(e) => setWeeklyHours(e.target.value)}
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SlideOver>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
components/employees/panels/PromotePanel.tsx
Normal file
114
components/employees/panels/PromotePanel.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { promoteEmployee } from "@/actions/employees";
|
||||||
|
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"];
|
||||||
|
|
||||||
|
const PAYGRADES: { value: PaygradeType; label: string }[] = [
|
||||||
|
{ value: "A", label: "A – Einstieg" },
|
||||||
|
{ value: "B", label: "B – Qualifiziert" },
|
||||||
|
{ value: "C", label: "C – Erfahren" },
|
||||||
|
{ value: "D", label: "D – Spezialist:in" },
|
||||||
|
{ value: "E", label: "E – Teamleitung" },
|
||||||
|
{ value: "F", label: "F – Bereichsleitung / GF" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function PromotePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
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) {
|
||||||
|
showToast("Bitte alle Pflichtfelder ausfüllen.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
const result = await promoteEmployee({
|
||||||
|
employee_id: employee.id,
|
||||||
|
effective_date: effectiveDate,
|
||||||
|
new_title: newTitle,
|
||||||
|
new_salary: Number(newSalary),
|
||||||
|
new_paygrade: paygrade,
|
||||||
|
});
|
||||||
|
setPending(false);
|
||||||
|
if (result.success) {
|
||||||
|
showToast(`${employee.first_name} ${employee.last_name} wurde befördert.`);
|
||||||
|
router.refresh();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideOver
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Beförderung"
|
||||||
|
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded bg-purple-text px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Befördern
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-ink">Wirksam ab*</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={effectiveDate}
|
||||||
|
onChange={(e) => setEffectiveDate(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">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
|
||||||
|
value={paygrade}
|
||||||
|
onChange={(e) => setPaygrade(e.target.value as PaygradeType)}
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{PAYGRADES.map((p) => (
|
||||||
|
<option key={p.value} value={p.value}>
|
||||||
|
{p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SlideOver>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
components/employees/panels/RehirePanel.tsx
Normal file
75
components/employees/panels/RehirePanel.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { rehireEmployee } from "@/actions/employees";
|
||||||
|
import { SlideOver } from "@/components/ui/SlideOver";
|
||||||
|
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"];
|
||||||
|
|
||||||
|
export function RehirePanel({ open, onClose, employee }: { open: boolean; onClose: () => void; employee: EmployeeRow }) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const [rehireDate, setRehireDate] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!rehireDate) {
|
||||||
|
showToast("Bitte ein Wiedereintrittsdatum angeben.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
const result = await rehireEmployee({ employee_id: employee.id, rehire_date: rehireDate });
|
||||||
|
setPending(false);
|
||||||
|
if (result.success) {
|
||||||
|
showToast(`${employee.first_name} ${employee.last_name} wurde wiedereingestellt.`);
|
||||||
|
router.refresh();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideOver
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Wiedereinstellung"
|
||||||
|
subtitle={`${employee.first_name} ${employee.last_name}`}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Wiedereinstellen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded border border-border bg-surface p-3 text-sm">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Letzte Position</p>
|
||||||
|
<p className="mt-1 text-ink">{employee.job_title}</p>
|
||||||
|
<p className="text-xs text-ink-muted">Ausgetreten am {fmtDate(employee.exit_date)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-ink">Wiedereintritt am*</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={rehireDate}
|
||||||
|
onChange={(e) => setRehireDate(e.target.value)}
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SlideOver>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
components/employees/panels/TerminatePanel.tsx
Normal file
116
components/employees/panels/TerminatePanel.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { terminateEmployee } from "@/actions/employees";
|
||||||
|
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"];
|
||||||
|
|
||||||
|
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"];
|
||||||
|
|
||||||
|
type TerminatePanelProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
employee: EmployeeRow;
|
||||||
|
directReportCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TerminatePanel({ open, onClose, employee, directReportCount }: TerminatePanelProps) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const [exitDate, setExitDate] = useState("");
|
||||||
|
const [reason, setReason] = useState(EXIT_REASONS[0]);
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [checked, setChecked] = useState<boolean[]>(CHECKLIST_ITEMS.map(() => false));
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!exitDate) {
|
||||||
|
showToast("Bitte ein Austrittsdatum angeben.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
const result = await terminateEmployee({ employee_id: employee.id, exit_date: exitDate, exit_reason: reason, note });
|
||||||
|
setPending(false);
|
||||||
|
if (result.success) {
|
||||||
|
showToast(`Austritt für ${employee.first_name} ${employee.last_name} erfasst.`);
|
||||||
|
router.refresh();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideOver
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Austritt"
|
||||||
|
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded bg-danger-solid px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Austritt bestätigen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{directReportCount > 0 && (
|
||||||
|
<div className="rounded bg-warning-bg px-3 py-2 text-sm text-warning-text">
|
||||||
|
{directReportCount} direkte Berichte werden automatisch der nächsthöheren Führungskraft zugeordnet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-ink">Austrittsdatum*</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={exitDate}
|
||||||
|
onChange={(e) => setExitDate(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">Beendigungsart</label>
|
||||||
|
<select value={reason} onChange={(e) => setReason(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm">
|
||||||
|
{EXIT_REASONS.map((r) => (
|
||||||
|
<option key={r} value={r}>
|
||||||
|
{r}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-ink">Anmerkung</label>
|
||||||
|
<textarea value={note} onChange={(e) => setNote(e.target.value)} rows={3} className="w-full rounded border border-border px-3 py-2 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-semibold text-ink">Offboarding-Checkliste</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{CHECKLIST_ITEMS.map((item, i) => (
|
||||||
|
<label key={item} className="flex items-center gap-2 text-sm text-ink-body">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked[i]}
|
||||||
|
onChange={(e) => setChecked((prev) => prev.map((c, idx) => (idx === i ? e.target.checked : c)))}
|
||||||
|
/>
|
||||||
|
{item}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SlideOver>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
components/employees/panels/TransferPanel.tsx
Normal file
133
components/employees/panels/TransferPanel.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { transferEmployee } from "@/actions/employees";
|
||||||
|
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 Division = Database["public"]["Tables"]["divisions"]["Row"];
|
||||||
|
type Department = Database["public"]["Tables"]["departments"]["Row"];
|
||||||
|
type Team = Database["public"]["Tables"]["teams"]["Row"];
|
||||||
|
|
||||||
|
type TransferPanelProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
employee: EmployeeRow;
|
||||||
|
divisions: Division[];
|
||||||
|
departments: Department[];
|
||||||
|
teams: Team[];
|
||||||
|
currentTeamId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TransferPanel({ open, onClose, employee, divisions, departments, teams, currentTeamId }: TransferPanelProps) {
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const [effectiveDate, setEffectiveDate] = useState("");
|
||||||
|
const [divisionId, setDivisionId] = useState(employee.division_id);
|
||||||
|
const [teamId, setTeamId] = useState(currentTeamId ?? "");
|
||||||
|
const [newTitle, setNewTitle] = useState("");
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
|
||||||
|
const teamsInDivision = useMemo(() => {
|
||||||
|
const deptIds = new Set(departments.filter((d) => d.division_id === divisionId).map((d) => d.id));
|
||||||
|
return teams.filter((t) => deptIds.has(t.department_id));
|
||||||
|
}, [departments, teams, divisionId]);
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!effectiveDate || !teamId) {
|
||||||
|
showToast("Bitte Datum und Zielteam angeben.", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(true);
|
||||||
|
const result = await transferEmployee({
|
||||||
|
employee_id: employee.id,
|
||||||
|
effective_date: effectiveDate,
|
||||||
|
new_team_id: teamId,
|
||||||
|
new_title: newTitle || undefined,
|
||||||
|
});
|
||||||
|
setPending(false);
|
||||||
|
if (result.success) {
|
||||||
|
showToast(`${employee.first_name} ${employee.last_name} wurde versetzt.`);
|
||||||
|
router.refresh();
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
showToast(result.error ?? "Fehler beim Speichern.", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideOver
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Versetzung"
|
||||||
|
subtitle={`${employee.first_name} ${employee.last_name} · ${employee.job_title}`}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<button onClick={onClose} className="rounded px-4 py-2 text-sm font-semibold text-ink-body hover:bg-surface">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={pending}
|
||||||
|
className="rounded bg-brand-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Versetzen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-ink">Wirksam ab*</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={effectiveDate}
|
||||||
|
onChange={(e) => setEffectiveDate(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">Neuer Bereich*</label>
|
||||||
|
<select
|
||||||
|
value={divisionId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDivisionId(e.target.value);
|
||||||
|
setTeamId("");
|
||||||
|
}}
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{divisions.map((d) => (
|
||||||
|
<option key={d.id} value={d.id}>
|
||||||
|
{d.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-ink">Neues Team*</label>
|
||||||
|
<select value={teamId} onChange={(e) => setTeamId(e.target.value)} className="w-full rounded border border-border px-3 py-2 text-sm">
|
||||||
|
<option value="">Bitte wählen…</option>
|
||||||
|
{teamsInDivision.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-ink">Neuer Titel (optional)</label>
|
||||||
|
<input
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
placeholder={employee.job_title}
|
||||||
|
className="w-full rounded border border-border px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-ink-muted">Die neue Führungskraft wird automatisch anhand des Zielteams bestimmt.</p>
|
||||||
|
</div>
|
||||||
|
</SlideOver>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
components/employees/tabs/HistorieTab.tsx
Normal file
35
components/employees/tabs/HistorieTab.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { actionBadgeStyle } from "@/lib/colors";
|
||||||
|
import { fmtDate } from "@/lib/format";
|
||||||
|
import type { Database } from "@/lib/supabase/types";
|
||||||
|
|
||||||
|
type HistoryRow = Database["public"]["Tables"]["employee_history"]["Row"];
|
||||||
|
|
||||||
|
export function HistorieTab({ history }: { history: HistoryRow[] }) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
return <p className="text-sm text-ink-muted">Keine Historieneinträge vorhanden.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-col divide-y divide-border">
|
||||||
|
{history.map((h) => {
|
||||||
|
const isFuture = h.event_date > today;
|
||||||
|
return (
|
||||||
|
<li key={h.id} className="py-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>{h.event_type}</span>
|
||||||
|
<span className="text-sm text-ink-muted">{fmtDate(h.event_date)}</span>
|
||||||
|
{isFuture && (
|
||||||
|
<span className="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-semibold text-warning-text">
|
||||||
|
⏱ zukünftig – wirksam ab {fmtDate(h.event_date)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-sm text-ink">{h.description}</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
components/employees/tabs/OrganisationTab.tsx
Normal file
59
components/employees/tabs/OrganisationTab.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
|
||||||
|
type MiniEmployee = { id: string; first_name: string; last_name: string; job_title: string; status?: string };
|
||||||
|
|
||||||
|
type OrganisationTabProps = {
|
||||||
|
manager: MiniEmployee | null;
|
||||||
|
directReports: MiniEmployee[];
|
||||||
|
breadcrumb: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrganisationTab({ manager, directReports, breadcrumb }: OrganisationTabProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-ink-muted">Organisationseinheit</h3>
|
||||||
|
<p className="mt-1 text-sm text-ink">{breadcrumb}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Führungskraft</h3>
|
||||||
|
{manager ? (
|
||||||
|
<Link href={`/employees/${manager.id}`} className="flex w-fit items-center gap-3 rounded border border-border p-3 hover:bg-surface">
|
||||||
|
<Avatar firstName={manager.first_name} lastName={manager.last_name} />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-ink">
|
||||||
|
{manager.first_name} {manager.last_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-muted">{manager.job_title}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-ink-muted">Keine Führungskraft (Geschäftsführung)</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-ink-muted">Direkte Berichte ({directReports.length})</h3>
|
||||||
|
{directReports.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{directReports.map((r) => (
|
||||||
|
<Link key={r.id} href={`/employees/${r.id}`} className="flex items-center gap-3 rounded border border-border p-3 hover:bg-surface">
|
||||||
|
<Avatar firstName={r.first_name} lastName={r.last_name} />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-ink">
|
||||||
|
{r.first_name} {r.last_name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-ink-muted">{r.job_title}</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-ink-muted">Keine direkten Berichte.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
components/employees/tabs/StammdatenTab.tsx
Normal file
28
components/employees/tabs/StammdatenTab.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { fmtAge, fmtDate } from "@/lib/format";
|
||||||
|
import type { Database } from "@/lib/supabase/types";
|
||||||
|
|
||||||
|
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||||
|
type Location = Database["public"]["Tables"]["locations"]["Row"];
|
||||||
|
|
||||||
|
export function StammdatenTab({ employee, location }: { employee: EmployeeRow; location?: Location }) {
|
||||||
|
const rows: [string, string][] = [
|
||||||
|
["Geburtsdatum", `${fmtDate(employee.birth_date)} (${fmtAge(employee.birth_date)} Jahre)`],
|
||||||
|
["SV-Nummer", employee.sv_nummer ?? "–"],
|
||||||
|
["Staatsbürgerschaft", employee.nationality],
|
||||||
|
["E-Mail", employee.email],
|
||||||
|
["Telefon", employee.phone ?? "–"],
|
||||||
|
["Standort", location ? `${location.name} (${location.country})` : "–"],
|
||||||
|
["Adresse", employee.address ?? "–"],
|
||||||
|
["Geschlecht", employee.gender === "m" ? "männlich" : "weiblich"],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{rows.map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{label}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-ink">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
components/employees/tabs/VertragTab.tsx
Normal file
38
components/employees/tabs/VertragTab.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { fmtDate, fmtEUR } from "@/lib/format";
|
||||||
|
import type { Database } from "@/lib/supabase/types";
|
||||||
|
|
||||||
|
type EmployeeRow = Database["public"]["Views"]["employees_directory"]["Row"];
|
||||||
|
|
||||||
|
const PAYGRADE_LABELS: Record<string, string> = {
|
||||||
|
A: "A – Einstieg",
|
||||||
|
B: "B – Qualifiziert",
|
||||||
|
C: "C – Erfahren",
|
||||||
|
D: "D – Spezialist:in",
|
||||||
|
E: "E – Teamleitung",
|
||||||
|
F: "F – Bereichsleitung / GF",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VertragTab({ employee }: { employee: EmployeeRow }) {
|
||||||
|
const rows: [string, string][] = [
|
||||||
|
["Eintrittsdatum", fmtDate(employee.entry_date)],
|
||||||
|
["Vertragsart", employee.contract_type === "befristet" ? `befristet bis ${fmtDate(employee.contract_end_date)}` : "unbefristet"],
|
||||||
|
["Kollektivvertrag", "Metalltechnische Industrie"],
|
||||||
|
["Beschäftigungsausmaß", employee.employment_type],
|
||||||
|
["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)]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-1 gap-x-8 gap-y-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{rows.map(([label, value]) => (
|
||||||
|
<div key={label}>
|
||||||
|
<dt className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{label}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-ink">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
lib/org.ts
Normal file
48
lib/org.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
|
import type { Database } from "./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"];
|
||||||
|
|
||||||
|
export type OrgMaps = {
|
||||||
|
divisions: Map<string, Division>;
|
||||||
|
departments: Map<string, Department>;
|
||||||
|
teams: Map<string, Team>;
|
||||||
|
locations: Map<string, Location>;
|
||||||
|
divisionList: Division[];
|
||||||
|
locationList: Location[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Org reference data is tiny (9 divisions / 16 departments / 35 teams / 5
|
||||||
|
// locations) — fetched whole and joined client-side rather than per-row.
|
||||||
|
export async function loadOrgMaps(supabase: SupabaseClient<Database>): Promise<OrgMaps> {
|
||||||
|
const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([
|
||||||
|
supabase.from("divisions").select("*").order("name"),
|
||||||
|
supabase.from("departments").select("*"),
|
||||||
|
supabase.from("teams").select("*"),
|
||||||
|
supabase.from("locations").select("*").order("name"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
divisions: new Map((divisions ?? []).map((d) => [d.id, d])),
|
||||||
|
departments: new Map((departments ?? []).map((d) => [d.id, d])),
|
||||||
|
teams: new Map((teams ?? []).map((t) => [t.id, t])),
|
||||||
|
locations: new Map((locations ?? []).map((l) => [l.id, l])),
|
||||||
|
divisionList: divisions ?? [],
|
||||||
|
locationList: locations ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function breadcrumbFor(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null) {
|
||||||
|
const division = divisionId ? orgMaps.divisions.get(divisionId) : undefined;
|
||||||
|
const team = teamId ? orgMaps.teams.get(teamId) : undefined;
|
||||||
|
const department = team ? orgMaps.departments.get(team.department_id) : undefined;
|
||||||
|
return { division, department, team };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function breadcrumbLabel(orgMaps: OrgMaps, divisionId: string | null, teamId: string | null): string {
|
||||||
|
const { division, department, team } = breadcrumbFor(orgMaps, divisionId, teamId);
|
||||||
|
return [division?.name, department?.name, team?.name].filter(Boolean).join(" › ") || "–";
|
||||||
|
}
|
||||||
@@ -281,6 +281,20 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
Functions: Record<string, never>;
|
Functions: {
|
||||||
|
hire_employee: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||||
|
terminate_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
|
transfer_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
|
promote_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
|
start_karenz: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
|
adjust_karenz_return: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
|
record_karenz_return: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
|
change_employee_data: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
|
rehire_employee: { Args: { payload: Record<string, unknown> }; Returns: void };
|
||||||
|
create_position: { Args: { payload: Record<string, unknown> }; Returns: string };
|
||||||
|
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 };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
551
supabase/functions.sql
Normal file
551
supabase/functions.sql
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
-- 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_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;
|
||||||
|
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;
|
||||||
|
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, coalesce(payload->>'job_title', v_position.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,
|
||||||
|
(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 ' || coalesce(payload->>'job_title', 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(), '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_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 := v_person_changes || 'Vorname'; end if;
|
||||||
|
if v_person ? 'last_name' and (v_person->>'last_name') <> v_old.last_name then v_person_changes := v_person_changes || 'Nachname'; end if;
|
||||||
|
if v_person ? 'gender' and (v_person->>'gender') <> v_old.gender::text then v_person_changes := 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 := 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 := v_person_changes || 'SV-Nummer'; end if;
|
||||||
|
if v_person ? 'nationality' and (v_person->>'nationality') <> v_old.nationality then v_person_changes := v_person_changes || 'Staatsbürgerschaft'; end if;
|
||||||
|
if v_person ? 'address' and coalesce(v_person->>'address','') <> coalesce(v_old.address,'') then v_person_changes := 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 := v_person_changes || 'Land'; end if;
|
||||||
|
if v_person ? 'email' and (v_person->>'email') <> v_old.email then v_person_changes := v_person_changes || 'E-Mail'; end if;
|
||||||
|
if v_person ? 'phone' and coalesce(v_person->>'phone','') <> coalesce(v_old.phone,'') then v_person_changes := 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 := 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 := 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 := v_contract_changes || 'Vertragsart'; end if;
|
||||||
|
if v_contract ? 'contract_end_date' and coalesce((v_contract->>'contract_end_date')::date::text,'') <> coalesce(v_old.contract_end_date::text,'') then v_contract_changes := 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, current_date, 'Stammdatenänderung', 'Geänderte Felder: ' || array_to_string(v_person_changes, ', '));
|
||||||
|
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, ', '));
|
||||||
|
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, current_date, 'Vertragsänderung', 'Geänderte Felder: ' || array_to_string(v_contract_changes, ', '));
|
||||||
|
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, ', '));
|
||||||
|
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,
|
||||||
|
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/functions_2.sql
Normal file
29
supabase/functions_2.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;
|
||||||
|
$$;
|
||||||
Reference in New Issue
Block a user