Scaffolds the Next.js 16 / TypeScript strict / Tailwind v3 app per NEXTJS_REBUILD_SUPERPROMPT.md, and implements the Foundation slice from the Phase 1 plan: - Corrected Supabase schema (supabase/schema.sql): org units, employees, history, positions, hire drafts, saved reports, audit log, reorg scenarios, role-based profiles, salary-masking view, RLS policies, auto-derivation triggers, position-number generator. - Seed script (supabase/seed.ts): ~800 realistic Austrian employees across 9 divisions / 16 departments / 35 teams, history, 8 open positions, and hr_admin/manager test accounts. - Supabase clients (lib/supabase/*), design tokens (tailwind.config.ts), format/color helpers (lib/format.ts, lib/colors.ts). - Shared UI kit (components/ui): Avatar, StatusChip, Toast, Modal, SlideOver, SegmentedControl, Lookup. - Auth (login page, Server Actions) and proxy.ts (Next 16's replacement for middleware) guarding the authenticated route group. - Shell (Sidebar, Topbar, NewHireButton stub) and the Dashboard page, reading live data via employees_directory. Employees list/detail, hire wizard, action panels, org chart, positions, reports, and audit log are deferred to later phases per the plan.
226 lines
9.2 KiB
TypeScript
226 lines
9.2 KiB
TypeScript
import Link from "next/link";
|
|
import { actionBadgeStyle } from "@/lib/colors";
|
|
import { fmtDate } from "@/lib/format";
|
|
import { createClient } from "@/lib/supabase/server";
|
|
|
|
function isoDate(d: Date): string {
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
const TONE_TEXT: Record<string, string> = {
|
|
default: "text-ink",
|
|
success: "text-success-text",
|
|
danger: "text-danger-text",
|
|
warning: "text-warning-text",
|
|
brand: "text-brand-700",
|
|
};
|
|
|
|
const DOT_STYLES: Record<string, string> = {
|
|
Eintritt: "bg-success-text",
|
|
Wiedereintritt: "bg-success-text",
|
|
Rückkehr: "bg-success-text",
|
|
Austritt: "bg-danger-text",
|
|
Versetzung: "bg-info-text",
|
|
Beförderung: "bg-purple-text",
|
|
Reorganisation: "bg-purple-text",
|
|
Karenz: "bg-warning-text",
|
|
Vertragsänderung: "bg-warning-text",
|
|
Stammdatenänderung: "bg-warning-text",
|
|
Gehaltsanpassung: "bg-warning-text",
|
|
};
|
|
|
|
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Karenz-Rückkehr" } as const;
|
|
|
|
export default async function DashboardPage() {
|
|
const supabase = await createClient();
|
|
|
|
const today = new Date();
|
|
const todayIso = isoDate(today);
|
|
const yearStart = isoDate(new Date(today.getFullYear(), 0, 1));
|
|
const yearEnd = isoDate(new Date(today.getFullYear(), 11, 31));
|
|
const in60 = new Date(today);
|
|
in60.setDate(in60.getDate() + 60);
|
|
const in60Iso = isoDate(in60);
|
|
|
|
const [
|
|
activeCountRes,
|
|
karenzCountRes,
|
|
hiresYtdRes,
|
|
exitsYtdRes,
|
|
openPositionsRes,
|
|
fteRowsRes,
|
|
divisionsRes,
|
|
headcountRowsRes,
|
|
upcomingHiresRes,
|
|
upcomingExitsRes,
|
|
upcomingReturnsRes,
|
|
historyRes,
|
|
] = await Promise.all([
|
|
supabase.from("employees_directory").select("id", { count: "exact", head: true }).in("status", ["Aktiv", "Karenz"]),
|
|
supabase.from("employees_directory").select("id", { count: "exact", head: true }).eq("status", "Karenz"),
|
|
supabase
|
|
.from("employees_directory")
|
|
.select("id", { count: "exact", head: true })
|
|
.gte("entry_date", yearStart)
|
|
.lte("entry_date", yearEnd),
|
|
supabase
|
|
.from("employees_directory")
|
|
.select("id", { count: "exact", head: true })
|
|
.gte("exit_date", yearStart)
|
|
.lte("exit_date", yearEnd),
|
|
supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
|
|
supabase.from("employees_directory").select("weekly_hours").in("status", ["Aktiv", "Karenz"]),
|
|
supabase.from("divisions").select("id, name"),
|
|
supabase.from("employees_directory").select("division_id").in("status", ["Aktiv", "Karenz"]),
|
|
supabase
|
|
.from("employees_directory")
|
|
.select("id, first_name, last_name, entry_date")
|
|
.eq("status", "Geplant")
|
|
.gte("entry_date", todayIso)
|
|
.lte("entry_date", in60Iso),
|
|
supabase
|
|
.from("employees_directory")
|
|
.select("id, first_name, last_name, exit_date")
|
|
.not("exit_date", "is", null)
|
|
.gte("exit_date", todayIso)
|
|
.lte("exit_date", in60Iso),
|
|
supabase
|
|
.from("employees_directory")
|
|
.select("id, first_name, last_name, karenz_return_date")
|
|
.eq("status", "Karenz")
|
|
.not("karenz_return_date", "is", null)
|
|
.gte("karenz_return_date", todayIso)
|
|
.lte("karenz_return_date", in60Iso),
|
|
supabase
|
|
.from("employee_history")
|
|
.select("id, employee_id, event_date, event_type, description")
|
|
.order("event_date", { ascending: false })
|
|
.order("created_at", { ascending: false })
|
|
.limit(10),
|
|
]);
|
|
|
|
const fte = (fteRowsRes.data ?? []).reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
|
|
|
|
const headcountByDivision = new Map<string, number>();
|
|
for (const row of headcountRowsRes.data ?? []) {
|
|
if (!row.division_id) continue;
|
|
headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
|
|
}
|
|
const divisionBars = (divisionsRes.data ?? [])
|
|
.map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 }))
|
|
.sort((a, b) => b.count - a.count);
|
|
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
|
|
|
|
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
|
|
const upcoming: UpcomingItem[] = [
|
|
...(upcomingHiresRes.data ?? []).map((e) => ({
|
|
id: e.id,
|
|
label: `${e.first_name} ${e.last_name}`,
|
|
date: e.entry_date,
|
|
kind: "hire" as const,
|
|
})),
|
|
...(upcomingExitsRes.data ?? []).map((e) => ({
|
|
id: e.id,
|
|
label: `${e.first_name} ${e.last_name}`,
|
|
date: e.exit_date!,
|
|
kind: "exit" as const,
|
|
})),
|
|
...(upcomingReturnsRes.data ?? []).map((e) => ({
|
|
id: e.id,
|
|
label: `${e.first_name} ${e.last_name}`,
|
|
date: e.karenz_return_date!,
|
|
kind: "return" as const,
|
|
})),
|
|
]
|
|
.sort((a, b) => a.date.localeCompare(b.date))
|
|
.slice(0, 8);
|
|
|
|
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
|
|
const historyEmployeesRes = historyEmployeeIds.length
|
|
? await supabase.from("employees_directory").select("id, first_name, last_name").in("id", historyEmployeeIds)
|
|
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
|
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
|
|
|
const kpis = [
|
|
{ label: "Aktive Mitarbeiter:innen", value: activeCountRes.count ?? 0, tone: "default" },
|
|
{ label: "FTE", value: fte.toFixed(1), tone: "default" },
|
|
{ label: "Eintritte (Jahr)", value: hiresYtdRes.count ?? 0, tone: "success" },
|
|
{ label: "Austritte (Jahr)", value: exitsYtdRes.count ?? 0, tone: "danger" },
|
|
{ label: "In Karenz", value: karenzCountRes.count ?? 0, tone: "warning" },
|
|
{ label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand" },
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
|
{kpis.map((kpi) => (
|
|
<div key={kpi.label} className="rounded border border-border bg-white p-4">
|
|
<div className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{kpi.label}</div>
|
|
<div className={`mt-2 text-2xl font-extrabold ${TONE_TEXT[kpi.tone]}`}>{kpi.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
<div className="rounded border border-border bg-white p-4">
|
|
<h2 className="mb-3 text-sm font-bold text-ink">Headcount nach Bereich</h2>
|
|
<div className="flex flex-col gap-2">
|
|
{divisionBars.map((d) => (
|
|
<div key={d.name}>
|
|
<div className="mb-0.5 flex justify-between text-xs text-ink-body">
|
|
<span>{d.name}</span>
|
|
<span className="font-semibold">{d.count}</span>
|
|
</div>
|
|
<div className="h-2 rounded bg-surface">
|
|
<div className="h-2 rounded bg-brand-500" style={{ width: `${(d.count / maxDivisionCount) * 100}%` }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
{divisionBars.length === 0 && <p className="text-sm text-ink-muted">Keine Daten vorhanden.</p>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded border border-border bg-white p-4">
|
|
<h2 className="mb-3 text-sm font-bold text-ink">Anstehend (60 Tage)</h2>
|
|
<ul className="flex flex-col divide-y divide-border">
|
|
{upcoming.map((item) => (
|
|
<li key={`${item.kind}-${item.id}`}>
|
|
<Link
|
|
href={`/employees/${item.id}`}
|
|
className="flex items-center justify-between py-2 text-sm hover:text-brand-700"
|
|
>
|
|
<span>
|
|
<span className="font-semibold text-ink">{item.label}</span>
|
|
<span className="ml-2 text-xs text-ink-muted">{KIND_LABEL[item.kind]}</span>
|
|
</span>
|
|
<span className="text-ink-muted">{fmtDate(item.date)}</span>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
{upcoming.length === 0 && <p className="py-2 text-sm text-ink-muted">Keine anstehenden Ereignisse.</p>}
|
|
</ul>
|
|
</div>
|
|
|
|
<div className="rounded border border-border bg-white p-4">
|
|
<h2 className="mb-3 text-sm font-bold text-ink">Letzte Aktivitäten</h2>
|
|
<ul className="flex flex-col divide-y divide-border">
|
|
{(historyRes.data ?? []).map((h) => (
|
|
<li key={h.id} className="py-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} />
|
|
<span className="text-sm font-semibold text-ink">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span>
|
|
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
|
{h.event_type}
|
|
</span>
|
|
</div>
|
|
<p className="mt-1 text-xs text-ink-muted">{h.description}</p>
|
|
</li>
|
|
))}
|
|
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|