diff --git a/actions/reports.ts b/actions/reports.ts new file mode 100644 index 0000000..0558ff1 --- /dev/null +++ b/actions/reports.ts @@ -0,0 +1,27 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { createClient } from "@/lib/supabase/server"; + +type ActionResult = { success: boolean; error?: string }; + +export async function saveReport(payload: { name: string; config: Record }): Promise { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { success: false, error: "Nicht angemeldet." }; + + const { error } = await supabase.from("saved_reports").insert({ created_by: user.id, name: payload.name, config: payload.config }); + if (error) return { success: false, error: error.message }; + revalidatePath("/reports"); + return { success: true }; +} + +export async function deleteReport(id: string): Promise { + const supabase = await createClient(); + const { error } = await supabase.from("saved_reports").delete().eq("id", id); + if (error) return { success: false, error: error.message }; + revalidatePath("/reports"); + return { success: true }; +} diff --git a/app/(app)/audit/page.tsx b/app/(app)/audit/page.tsx new file mode 100644 index 0000000..d149e3e --- /dev/null +++ b/app/(app)/audit/page.tsx @@ -0,0 +1,119 @@ +import Link from "next/link"; +import { Suspense } from "react"; +import { AuditFilters } from "@/components/audit/AuditFilters"; +import { actionBadgeStyle } from "@/lib/colors"; +import { fmtDate } from "@/lib/format"; +import { createClient } from "@/lib/supabase/server"; + +const PAGE_SIZE = 25; + +type SearchParams = { q?: string; action?: string; page?: string }; + +function pageHref(params: SearchParams, page: number): string { + const sp = new URLSearchParams(); + if (params.q) sp.set("q", params.q); + if (params.action) sp.set("action", params.action); + sp.set("page", String(page)); + return `/audit?${sp.toString()}`; +} + +function fmtDateTime(iso: string): string { + return new Intl.DateTimeFormat("de-AT", { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" }).format( + new Date(iso) + ); +} + +export default async function AuditPage({ searchParams }: { searchParams: Promise }) { + const params = await searchParams; + const supabase = await createClient(); + + 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("audit_log") + .select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" }) + .order("occurred_at", { ascending: false }) + .range(from, to); + + if (params.action) query = query.eq("action", params.action); + if (params.q) { + const q = params.q.trim(); + query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`); + } + + const { data: entries, count } = await query; + const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE)); + + return ( +
+ + + +

{count ?? 0} Einträge

+ +
+ + + + + + + + + + + + {(entries ?? []).map((entry) => { + return ( + + + + + + + + ); + })} + {(entries ?? []).length === 0 && ( + + + + )} + +
ZeitpunktBenutzer:inAktionObjektDetails
{fmtDateTime(entry.occurred_at)}{entry.actor_name} + {entry.action} + + {entry.target_employee_id ? ( + + {entry.target_label} + + ) : ( + entry.target_label + )} + {entry.details ?? "–"}
+ Keine Einträge gefunden. +
+
+ + {totalPages > 1 && ( +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => ( + + {p} + + ))} +
+ )} + +

+ Alle Änderungen an Personal-Stammdaten werden automatisch protokolliert und sind unveränderbar. +

+
+ ); +} diff --git a/app/(app)/reports/page.tsx b/app/(app)/reports/page.tsx new file mode 100644 index 0000000..ef5c8a2 --- /dev/null +++ b/app/(app)/reports/page.tsx @@ -0,0 +1,117 @@ +import { Suspense } from "react"; +import { ReportsPageClient } from "@/components/reports/ReportsPageClient"; +import { + aggregateReport, + DATE_SCOPED_MEASURES, + type GroupDimension, + type Measure, + type OrgLookups, + type ReportEmployee, +} from "@/lib/reports"; +import { createClient } from "@/lib/supabase/server"; +import type { EmploymentStatus, EmploymentType } from "@/lib/supabase/types"; + +type SearchParams = { + measure?: string; + group?: string; + split?: string; + division?: string; + location?: string; + status?: string; + employment?: string; + from?: string; + to?: string; +}; + +export default async function ReportsPage({ searchParams }: { searchParams: Promise }) { + const params = await searchParams; + const supabase = await createClient(); + + const measure = (params.measure as Measure) || "headcount"; + const group = (params.group as GroupDimension) || "division"; + const split = (params.split as GroupDimension) || undefined; + + const [{ data: divisions }, { data: departments }, { data: teams }, { data: locations }] = await Promise.all([ + supabase.from("divisions").select("id, name").order("name"), + supabase.from("departments").select("id, name"), + supabase.from("teams").select("id, name, department_id"), + supabase.from("locations").select("id, name").order("name"), + ]); + + const departmentNameById = new Map((departments ?? []).map((d) => [d.id, d.name])); + const lookups: OrgLookups = { + divisionName: new Map((divisions ?? []).map((d) => [d.id, d.name])), + departmentNameByTeam: new Map((teams ?? []).map((t) => [t.id, departmentNameById.get(t.department_id) ?? "Unbekannt"])), + teamName: new Map((teams ?? []).map((t) => [t.id, t.name])), + locationName: new Map((locations ?? []).map((l) => [l.id, l.name])), + }; + + let query = supabase + .from("employees_directory") + .select( + "id, first_name, last_name, job_title, division_id, team_id, location_id, status, employment_type, contract_type, entry_date, exit_date, weekly_hours, monthly_salary_gross, source, paygrade, birth_date, gender" + ); + + if (params.division) query = query.eq("division_id", params.division); + if (params.location) query = query.eq("location_id", params.location); + if (params.status) query = query.eq("status", params.status as EmploymentStatus); + if (params.employment) query = query.eq("employment_type", params.employment as EmploymentType); + + const isDateScoped = DATE_SCOPED_MEASURES.includes(measure); + const currentYear = new Date().getFullYear(); + const from = params.from || `${currentYear}-01-01`; + const to = params.to || `${currentYear}-12-31`; + if (isDateScoped) { + if (measure === "hires") query = query.gte("entry_date", from).lte("entry_date", to); + else query = query.gte("exit_date", from).lte("exit_date", to).not("exit_date", "is", null); + } else if (!params.status) { + query = query.in("status", ["Aktiv", "Karenz"]); + } + + const { data: employeesData } = await query; + const employees = (employeesData ?? []) as ReportEmployee[]; + + const rows = aggregateReport(employees, measure, group, split ?? null, lookups); + const total = measureValueForTotal(rows, measure); + + const { + data: { user }, + } = await supabase.auth.getUser(); + const { data: savedReports } = user + ? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false }) + : { data: [] }; + + return ( + + + + ); +} + +function measureValueForTotal(rows: { value: number; count: number }[], measure: Measure): number { + if (["headcount", "fte", "hires", "exits"].includes(measure)) { + return rows.reduce((s, r) => s + r.value, 0); + } + // averages/ratios: weight by underlying count for a sensible overall figure + const totalCount = rows.reduce((s, r) => s + r.count, 0); + if (totalCount === 0) return 0; + return rows.reduce((s, r) => s + r.value * r.count, 0) / totalCount; +} diff --git a/components/audit/AuditFilters.tsx b/components/audit/AuditFilters.tsx new file mode 100644 index 0000000..2c3f1f4 --- /dev/null +++ b/components/audit/AuditFilters.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Search } from "lucide-react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +const ACTIONS = [ + "Neueinstellung", + "Wiedereinstellung", + "Rückkehr", + "Austritt", + "Versetzung", + "Ausschreibung", + "Interne Besetzung", + "Beförderung", + "Reorganisation", + "Reorganisation rückgängig", + "Karenz", + "Vertragsänderung", + "Stammdatenänderung", + "Gehaltsanpassung", +]; + +export function AuditFilters() { + 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 ( +
+
+ + setQ(e.target.value)} + placeholder="Objekt, Details, Benutzer:in…" + className="w-full text-sm text-ink outline-none placeholder:text-ink-muted" + /> +
+ +
+ ); +} diff --git a/components/reports/ReportsPageClient.tsx b/components/reports/ReportsPageClient.tsx new file mode 100644 index 0000000..3759b07 --- /dev/null +++ b/components/reports/ReportsPageClient.tsx @@ -0,0 +1,354 @@ +"use client"; + +import { Download, Save, Trash2 } from "lucide-react"; +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; +import { deleteReport, saveReport } from "@/actions/reports"; +import { useToast } from "@/components/ui/Toast"; +import { fmtDate } from "@/lib/format"; +import { + AVERAGE_MEASURES, + DATE_SCOPED_MEASURES, + GROUP_LABELS, + MEASURE_LABELS, + REPORT_PRESETS, + type GroupDimension, + type Measure, + type ReportRow, +} from "@/lib/reports"; + +const SPLIT_COLORS = ["bg-brand-500", "bg-info-text", "bg-purple-text", "bg-warning-text", "bg-success-text", "bg-danger-solid"]; + +type SavedReport = { id: string; name: string; config: Record }; + +type ReportsPageClientProps = { + measure: Measure; + group: GroupDimension; + split: GroupDimension | ""; + filters: { division: string; location: string; status: string; employment: string; from: string; to: string }; + rows: ReportRow[]; + total: number; + recordCount: number; + divisions: { id: string; name: string }[]; + locations: { id: string; name: string }[]; + savedReports: SavedReport[]; +}; + +function formatValue(measure: Measure, value: number): string { + if (["headcount", "hires", "exits"].includes(measure)) return String(Math.round(value)); + if (measure === "fte") return value.toFixed(1); + if (measure === "avg_salary") return new Intl.NumberFormat("de-AT", { style: "currency", currency: "EUR" }).format(value); + if (measure === "parttime_rate" || measure === "female_share") return `${value.toFixed(1)}%`; + if (measure === "avg_age" || measure === "avg_tenure") return `${value.toFixed(1)} Jahre`; + return value.toFixed(1); +} + +export function ReportsPageClient(props: ReportsPageClientProps) { + const { measure, group, split, filters, rows, total, recordCount, divisions, locations, savedReports } = props; + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { showToast } = useToast(); + const [expandedKey, setExpandedKey] = useState(null); + + function updateParams(patch: Record) { + const sp = new URLSearchParams(searchParams.toString()); + for (const [k, v] of Object.entries(patch)) { + if (v) sp.set(k, v); + else sp.delete(k); + } + router.push(`${pathname}?${sp.toString()}`); + } + + function applyPreset(preset: (typeof REPORT_PRESETS)[number]) { + router.push(`${pathname}?measure=${preset.measure}&group=${preset.group}${preset.split ? `&split=${preset.split}` : ""}`); + } + + function applySavedReport(config: Record) { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(config)) { + if (typeof v === "string" && v) sp.set(k, v); + } + router.push(`${pathname}?${sp.toString()}`); + } + + async function handleSaveReport() { + const name = window.prompt("Name für diesen Bericht:"); + if (!name) return; + const result = await saveReport({ name, config: { measure, group, split, ...filters } }); + if (result.success) { + showToast("Bericht gespeichert."); + router.refresh(); + } else { + showToast(result.error ?? "Fehler beim Speichern.", "error"); + } + } + + async function handleDeleteReport(id: string) { + const result = await deleteReport(id); + if (result.success) { + showToast("Bericht gelöscht."); + router.refresh(); + } else { + showToast(result.error ?? "Fehler beim Löschen.", "error"); + } + } + + function handleExportCsv() { + const header = [GROUP_LABELS[group], MEASURE_LABELS[measure], "Anzahl", "Anteil (%)"]; + const lines = [header.join(";")]; + for (const row of rows) { + const share = total > 0 ? ((row.value / total) * 100).toFixed(1) : "0"; + lines.push([row.key, formatValue(measure, row.value), String(row.count), share].join(";")); + } + const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `bericht-${measure}-${group}.csv`; + a.click(); + URL.revokeObjectURL(url); + } + + const isAverage = AVERAGE_MEASURES.includes(measure); + const isDateScoped = DATE_SCOPED_MEASURES.includes(measure); + const maxValue = Math.max(1, ...rows.map((r) => r.value)); + + return ( +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

Filter

+
+ + + + + {isDateScoped && ( +
+ updateParams({ from: e.target.value })} + className="w-full rounded border border-border px-2 py-2 text-xs" + /> + updateParams({ to: e.target.value })} + className="w-full rounded border border-border px-2 py-2 text-xs" + /> +
+ )} +
+
+ +
+

Vorlagen

+
+ {REPORT_PRESETS.map((preset) => ( + + ))} +
+
+ + {savedReports.length > 0 && ( +
+

Gespeicherte Berichte

+
    + {savedReports.map((r) => ( +
  • + + +
  • + ))} +
+
+ )} +
+ +
+
+
+

+ {MEASURE_LABELS[measure]} nach {GROUP_LABELS[group]} +

+

{recordCount} Datensätze

+
+
+ + +
+
+ +

{formatValue(measure, total)}

+ + {split && ( +
+ {Array.from(new Set(rows.flatMap((r) => r.split?.map((s) => s.key) ?? []))).map((key, i) => ( + + + {key} + + ))} +
+ )} + +
+ {rows.map((row) => { + const share = total > 0 ? (row.value / total) * 100 : 0; + const isExpanded = expandedKey === row.key; + return ( +
+ + {isExpanded && ( +
+
    + {row.people.slice(0, 12).map((p) => ( +
  • + + {p.name} + + + {p.title} · {p.team} · {fmtDate(p.entry_date)} + +
  • + ))} +
+ {row.people.length > 12 &&

+{row.people.length - 12} weitere

} +
+ )} +
+ ); + })} + {rows.length === 0 &&

Keine Daten für diese Auswahl.

} +
+
+
+ ); +} diff --git a/lib/reports.ts b/lib/reports.ts new file mode 100644 index 0000000..8ce1dd2 --- /dev/null +++ b/lib/reports.ts @@ -0,0 +1,201 @@ +export type Measure = + | "headcount" + | "fte" + | "hires" + | "exits" + | "avg_salary" + | "parttime_rate" + | "avg_age" + | "avg_tenure" + | "female_share"; + +export type GroupDimension = + | "division" + | "department" + | "team" + | "location" + | "status" + | "employment_type" + | "contract_type" + | "entry_year" + | "source" + | "paygrade"; + +export const MEASURE_LABELS: Record = { + headcount: "Headcount", + fte: "FTE", + hires: "Eintritte", + exits: "Austritte", + avg_salary: "Ø Bruttogehalt", + parttime_rate: "Teilzeitquote", + avg_age: "Ø Alter", + avg_tenure: "Ø Zugehörigkeit", + female_share: "Frauenanteil", +}; + +export const GROUP_LABELS: Record = { + division: "Bereich", + department: "Abteilung", + team: "Team", + location: "Standort", + status: "Status", + employment_type: "Beschäftigung", + contract_type: "Vertragsart", + entry_year: "Eintrittsjahr", + source: "Intern/Extern", + paygrade: "Paygrade", +}; + +export const AVERAGE_MEASURES: Measure[] = ["avg_salary", "parttime_rate", "avg_age", "avg_tenure", "female_share"]; +export const DATE_SCOPED_MEASURES: Measure[] = ["hires", "exits"]; + +export type ReportEmployee = { + id: string; + first_name: string; + last_name: string; + job_title: string; + division_id: string; + team_id: string | null; + location_id: string; + status: string; + employment_type: string; + contract_type: string; + entry_date: string; + exit_date: string | null; + weekly_hours: number; + monthly_salary_gross: number | null; + source: string; + paygrade: string; + birth_date: string; + gender: string; +}; + +export type OrgLookups = { + divisionName: Map; + departmentNameByTeam: Map; + teamName: Map; + locationName: Map; +}; + +function ageFromBirthDate(birthDate: string): number { + const d = new Date(birthDate); + const today = new Date(); + let age = today.getFullYear() - d.getFullYear(); + if (today.getMonth() < d.getMonth() || (today.getMonth() === d.getMonth() && today.getDate() < d.getDate())) age -= 1; + return age; +} + +function tenureYears(entryDate: string, exitDate: string | null): number { + const start = new Date(entryDate); + const end = exitDate ? new Date(exitDate) : new Date(); + return Math.max(0, (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 365.25)); +} + +export function groupKeyFor(e: ReportEmployee, dim: GroupDimension, lookups: OrgLookups): string { + switch (dim) { + case "division": + return lookups.divisionName.get(e.division_id) ?? "Unbekannt"; + case "department": + return e.team_id ? (lookups.departmentNameByTeam.get(e.team_id) ?? "Unbekannt") : "–"; + case "team": + return e.team_id ? (lookups.teamName.get(e.team_id) ?? "Unbekannt") : "–"; + case "location": + return lookups.locationName.get(e.location_id) ?? "Unbekannt"; + case "status": + return e.status; + case "employment_type": + return e.employment_type; + case "contract_type": + return e.contract_type; + case "entry_year": + return String(new Date(e.entry_date).getFullYear()); + case "source": + return e.source; + case "paygrade": + return e.paygrade; + default: + return "Unbekannt"; + } +} + +export function measureValue(rows: ReportEmployee[], measure: Measure): number { + if (rows.length === 0) return 0; + switch (measure) { + case "headcount": + case "hires": + case "exits": + return rows.length; + case "fte": + return rows.reduce((s, e) => s + e.weekly_hours / 38.5, 0); + case "avg_salary": { + const withSalary = rows.filter((e) => e.monthly_salary_gross != null); + return withSalary.length ? withSalary.reduce((s, e) => s + (e.monthly_salary_gross ?? 0), 0) / withSalary.length : 0; + } + case "parttime_rate": + return (rows.filter((e) => e.employment_type === "Teilzeit").length / rows.length) * 100; + case "avg_age": + return rows.reduce((s, e) => s + ageFromBirthDate(e.birth_date), 0) / rows.length; + case "avg_tenure": + return rows.reduce((s, e) => s + tenureYears(e.entry_date, e.exit_date), 0) / rows.length; + case "female_share": + return (rows.filter((e) => e.gender === "w").length / rows.length) * 100; + default: + return 0; + } +} + +export type ReportPerson = { id: string; name: string; title: string; team: string; entry_date: string }; +export type ReportSplitRow = { key: string; value: number; count: number }; +export type ReportRow = { key: string; value: number; count: number; people: ReportPerson[]; split?: ReportSplitRow[] }; + +export function aggregateReport( + employees: ReportEmployee[], + measure: Measure, + group: GroupDimension, + split: GroupDimension | null, + lookups: OrgLookups +): ReportRow[] { + const byGroup = new Map(); + for (const e of employees) { + const key = groupKeyFor(e, group, lookups); + if (!byGroup.has(key)) byGroup.set(key, []); + byGroup.get(key)!.push(e); + } + + const rows: ReportRow[] = []; + for (const [key, rowsForGroup] of byGroup) { + const value = measureValue(rowsForGroup, measure); + const people: ReportPerson[] = rowsForGroup.map((e) => ({ + id: e.id, + name: `${e.first_name} ${e.last_name}`, + title: e.job_title, + team: e.team_id ? (lookups.teamName.get(e.team_id) ?? "–") : "–", + entry_date: e.entry_date, + })); + const row: ReportRow = { key, value, count: rowsForGroup.length, people }; + if (split) { + const bySplit = new Map(); + for (const e of rowsForGroup) { + const sKey = groupKeyFor(e, split, lookups); + if (!bySplit.has(sKey)) bySplit.set(sKey, []); + bySplit.get(sKey)!.push(e); + } + row.split = Array.from(bySplit.entries()).map(([sKey, sRows]) => ({ + key: sKey, + value: measureValue(sRows, measure), + count: sRows.length, + })); + } + rows.push(row); + } + return rows.sort((a, b) => b.value - a.value); +} + +export const REPORT_PRESETS: { name: string; measure: Measure; group: GroupDimension; split?: GroupDimension }[] = [ + { name: "Headcount nach Bereich", measure: "headcount", group: "division" }, + { name: "Frauenanteil nach Bereich", measure: "female_share", group: "division" }, + { name: "Ø Gehalt nach Paygrade", measure: "avg_salary", group: "paygrade" }, + { name: "Teilzeitquote nach Standort", measure: "parttime_rate", group: "location" }, + { name: "Eintritte nach Bereich", measure: "hires", group: "division" }, + { name: "Austritte nach Abteilung", measure: "exits", group: "department" }, +];