Consolidation pass: HR-only access, effective-dated mutations, data integrity guards, test suite
Reworks the app from a two-role (hr_admin/manager) model to a single HR-only role gated by profiles.is_active, fixes transfer/promote/karenz/ reorg RPCs to actually defer future-dated changes via a new pending_org_changes table instead of writing them immediately (applied by a daily Vercel Cron route), makes reorg undo append-only instead of deleting history, adds Karenz-return and history-date integrity guards, deprecates the salary column, and adds explicit schema grants + perf indexes needed to run against a fresh (non-hosted) Postgres instance. Adds vitest unit + integration test suites (the latter against a real local Supabase instance) covering all of the above, plus lint/typecheck/ build wiring (`npm run check`).
This commit is contained in:
@@ -2,7 +2,6 @@ import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { AuditFilters } from "@/components/audit/AuditFilters";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { fmtDate } from "@/lib/format";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -8,22 +8,16 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
const { data: profile } = await supabase.from("profiles").select("role").eq("id", user!.id).single();
|
||||
const canEdit = profile?.role === "hr_admin";
|
||||
|
||||
const { data: employee } = await supabase.from("employees_directory").select("*").eq("id", id).single();
|
||||
const { data: employee } = await supabase.from("employees").select("*").eq("id", id).single();
|
||||
if (!employee) notFound();
|
||||
|
||||
const [{ data: manager }, { data: directReports }, { data: history }, { data: divisions }, { data: departments }, { data: teams }, { data: locations }, { data: openPositions }] =
|
||||
await Promise.all([
|
||||
employee.manager_id
|
||||
? supabase.from("employees_directory").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single()
|
||||
? supabase.from("employees").select("id, first_name, last_name, job_title").eq("id", employee.manager_id).single()
|
||||
: Promise.resolve({ data: null }),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, job_title, status")
|
||||
.eq("manager_id", id)
|
||||
.order("last_name"),
|
||||
@@ -46,7 +40,6 @@ export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
teams={teams ?? []}
|
||||
locations={locations ?? []}
|
||||
openPositions={openPositions ?? []}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
const to = from + PAGE_SIZE - 1;
|
||||
|
||||
let query = supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select(
|
||||
"id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status",
|
||||
{ count: "exact" }
|
||||
|
||||
@@ -13,25 +13,27 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect("/login");
|
||||
|
||||
const { data: profile } = await supabase.from("profiles").select("full_name, email, role").eq("id", user.id).single();
|
||||
const canEdit = profile?.role === "hr_admin";
|
||||
// Defense in depth: proxy.ts already redirects any non-active-HR session
|
||||
// away before this layout ever renders. Re-checking here means a gap in
|
||||
// the proxy matcher (or a future route added outside it) still fails
|
||||
// closed instead of silently granting access — see docs/security.md.
|
||||
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle();
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login");
|
||||
|
||||
const userLabel = profile?.full_name || profile?.email || user.email || "";
|
||||
const userLabel = profile.full_name || profile.email || user.email || "";
|
||||
|
||||
const [openPositions, locationsRes, draftsRes] = canEdit
|
||||
? await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("locations").select("id, name, country").order("name"),
|
||||
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
||||
])
|
||||
: [[], { data: [] }, { data: [] }];
|
||||
const [openPositions, locationsRes, draftsRes] = await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("locations").select("id, name, country").order("name"),
|
||||
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="ml-[236px] flex flex-1 flex-col">
|
||||
<Topbar userLabel={userLabel} role={profile?.role} canEdit={canEdit} />
|
||||
<Topbar userLabel={userLabel} />
|
||||
<main className="flex-1 px-6 py-6">
|
||||
<div className="mx-auto w-full max-w-[1280px]">{children}</div>
|
||||
</main>
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function OrgChartPage() {
|
||||
{ data: reorgScenarios },
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, personnel_number, first_name, last_name, job_title, manager_id, team_id, division_id, is_lead, org_level")
|
||||
.in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
|
||||
@@ -67,36 +67,36 @@ export default async function DashboardPage() {
|
||||
upcomingReturnsRes,
|
||||
historyRes,
|
||||
] = await Promise.all([
|
||||
supabase.from("employees_directory").select("id", { count: "exact", head: true }).in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("employees_directory").select("id", { count: "exact", head: true }).eq("status", "Karenz"),
|
||||
supabase.from("employees").select("id", { count: "exact", head: true }).in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("employees").select("id", { count: "exact", head: true }).eq("status", "Karenz"),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("entry_date", yearStart)
|
||||
.lte("entry_date", yearEnd),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("exit_date", yearStart)
|
||||
.lte("exit_date", yearEnd),
|
||||
supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
|
||||
supabase.from("employees_directory").select("weekly_hours").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("employees").select("weekly_hours").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("divisions").select("id, name"),
|
||||
supabase.from("employees_directory").select("division_id").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("employees").select("division_id").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, entry_date")
|
||||
.eq("status", "Geplant")
|
||||
.gte("entry_date", todayIso)
|
||||
.lte("entry_date", in60Iso),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, exit_date")
|
||||
.not("exit_date", "is", null)
|
||||
.gte("exit_date", todayIso)
|
||||
.lte("exit_date", in60Iso),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, karenz_return_date")
|
||||
.eq("status", "Karenz")
|
||||
.not("karenz_return_date", "is", null)
|
||||
@@ -148,7 +148,7 @@ export default async function DashboardPage() {
|
||||
|
||||
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
|
||||
const historyEmployeesRes = historyEmployeeIds.length
|
||||
? await supabase.from("employees_directory").select("id, first_name, last_name").in("id", historyEmployeeIds)
|
||||
? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds)
|
||||
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
||||
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ export default async function PositionsPage() {
|
||||
supabase.from("divisions").select("*").order("name"),
|
||||
supabase.from("departments").select("*"),
|
||||
supabase.from("teams").select("*"),
|
||||
supabase.from("employees_directory").select("team_id, division_id, weekly_hours").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase.from("employees").select("team_id, division_id, weekly_hours").in("status", ["Aktiv", "Karenz"]),
|
||||
supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, team_id, division_id, org_level, is_lead")
|
||||
.eq("status", "Aktiv")
|
||||
.or("is_lead.eq.true,org_level.eq.0"),
|
||||
|
||||
@@ -47,9 +47,9 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
};
|
||||
|
||||
let query = supabase
|
||||
.from("employees_directory")
|
||||
.from("employees")
|
||||
.select(
|
||||
"id, first_name, last_name, job_title, division_id, team_id, location_id, status, employment_type, contract_type, entry_date, exit_date, weekly_hours, monthly_salary_gross, source, paygrade, birth_date, gender"
|
||||
"id, first_name, last_name, job_title, division_id, team_id, location_id, status, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender"
|
||||
);
|
||||
|
||||
if (params.division) query = query.eq("division_id", params.division);
|
||||
|
||||
Reference in New Issue
Block a user