Visual pass, clickable KPI tiles, and one consistent definition of status
Visual - `--radius: 8px` in @theme collapsed Tailwind v4's whole radius scale onto a single value: `rounded` and `rounded-lg` both measured 8px, so a chip, an input and a card could not be told apart. Named steps restore the gradation (6 / 8 / 12px, measured in the browser). - Cards were a 1px border and nothing else. Added warm, brand-tinted elevation tokens — a neutral black shadow over the pink surface reads as dirt — in three steps for cards, dropdowns and overlays, collected behind components/ui/Card.tsx so the 26 hand-copied card class chains have one definition. - KPI tiles lead with the number and carry a tone accent; tables got denser rows, subtle row rules (the full border strength made 800 rows read as a grid), tabular figures in numeric columns and a brand-tinted hover. KPI tiles now link to the view that shows what they count. Making those links honest surfaced two reasons the numbers did not agree with their destinations: - The dashboard read `employees.status`, while every report derives status from entry/exit/karenz dates. A hire whose start date had passed before the cron ran was counted differently on the two pages. The dashboard now uses the same derivation — and one query instead of five. - Eintritte/Austritte counted `entry_date`/`exit_date` while the linked report counts `employee_history`; rehire_employee sets entry_date but logs the event as 'Wiedereintritt', so rehires were missing from the target. Both now count history events. - The employee list filtered on the status column, so it disagreed too. It now filters on derived status in SQL (lib/employee-status-filter.ts). That restates deriveStatusAsOf a second time, in a second language, so an integration test runs both over the full roster and requires identical id sets — drift here is otherwise invisible. Status semantics, per the domain correction: "aktiv" means status Aktiv alone. Karenz is employed but not active, and has its own tile. The active headcount, FTE (Karenz contributes no capacity) and the division bars all follow that; the bars are labelled "Aktive nach Bereich" rather than "Headcount" to say so. The employee filter still offers the combination, named after the two statuses it selects instead of calling the pair active. DEFAULT_STATUSES in lib/reports.ts is deliberately left at Aktiv + Karenz: it governs what the Berichte page shows without an explicit status filter, and therefore what already-saved reports and exports mean.
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { AuditFilters } from "@/components/audit/AuditFilters";
|
import { AuditFilters } from "@/components/audit/AuditFilters";
|
||||||
|
import { CARD_CLASS } from "@/components/ui/Card";
|
||||||
import { Pagination } from "@/components/ui/Pagination";
|
import { Pagination } from "@/components/ui/Pagination";
|
||||||
import { actionBadgeStyle } from "@/lib/colors";
|
import { actionBadgeStyle } from "@/lib/colors";
|
||||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||||
@@ -61,36 +62,43 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
<p className="text-sm text-ink-muted">{count ?? 0} Einträge</p>
|
<p className="text-sm text-ink-muted">{count ?? 0} Einträge</p>
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded border border-border bg-white">
|
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||||
<table className="w-full min-w-[800px] text-sm">
|
<table className="w-full min-w-[800px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||||
<th className="px-4 py-3">Zeitpunkt</th>
|
<th className="px-4 py-2.5">Zeitpunkt</th>
|
||||||
<th className="px-4 py-3">Benutzer:in</th>
|
<th className="px-4 py-2.5">Benutzer:in</th>
|
||||||
<th className="px-4 py-3">Aktion</th>
|
<th className="px-4 py-2.5">Aktion</th>
|
||||||
<th className="px-4 py-3">Objekt</th>
|
<th className="px-4 py-2.5">Objekt</th>
|
||||||
<th className="px-4 py-3">Details</th>
|
<th className="px-4 py-2.5">Details</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(entries ?? []).map((entry) => {
|
{(entries ?? []).map((entry) => {
|
||||||
return (
|
return (
|
||||||
<tr key={entry.id} className="border-b border-border last:border-0 hover:bg-surface">
|
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
||||||
<td className="px-4 py-3 text-ink-body">{dateTimeFormatter.format(new Date(entry.occurred_at))}</td>
|
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-ink-body">
|
||||||
<td className="px-4 py-3 text-ink-body">{entry.actor_name}</td>
|
{dateTimeFormatter.format(new Date(entry.occurred_at))}
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold ${actionBadgeStyle(entry.action)}`}>{entry.action}</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-ink">
|
<td className="px-4 py-2.5 text-ink-body">{entry.actor_name}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<span className={`whitespace-nowrap rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(entry.action)}`}>
|
||||||
|
{entry.action}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink">
|
||||||
{entry.target_employee_id ? (
|
{entry.target_employee_id ? (
|
||||||
<Link href={`/employees/${entry.target_employee_id}`} className="hover:text-brand-700 hover:underline">
|
<Link
|
||||||
|
href={`/employees/${entry.target_employee_id}`}
|
||||||
|
className="rounded font-semibold hover:text-brand-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
{entry.target_label}
|
{entry.target_label}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
entry.target_label
|
entry.target_label
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-ink-muted">{entry.details ?? "–"}</td>
|
<td className="px-4 py-2.5 text-ink-muted">{entry.details ?? "–"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ import Link from "next/link";
|
|||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { EmployeeFilters } from "@/components/employees/EmployeeFilters";
|
import { EmployeeFilters } from "@/components/employees/EmployeeFilters";
|
||||||
import { Avatar } from "@/components/ui/Avatar";
|
import { Avatar } from "@/components/ui/Avatar";
|
||||||
|
import { CARD_CLASS } from "@/components/ui/Card";
|
||||||
import { Pagination } from "@/components/ui/Pagination";
|
import { Pagination } from "@/components/ui/Pagination";
|
||||||
import { StatusChip } from "@/components/ui/StatusChip";
|
import { StatusChip } from "@/components/ui/StatusChip";
|
||||||
import { fmtDate } from "@/lib/format";
|
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
|
||||||
|
import { fmtDate, todayIso } from "@/lib/format";
|
||||||
import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
|
import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
|
||||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
@@ -56,7 +58,17 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (params.division) query = query.eq("division_id", params.division);
|
if (params.division) query = query.eq("division_id", params.division);
|
||||||
if (params.status) query = query.eq("status", params.status as EmploymentStatus);
|
// Comma-separated, so a dashboard tile can link here with the same
|
||||||
|
// definition it counted — "aktiv" across this app means Aktiv *and*
|
||||||
|
// Karenz, and a single-value filter would land the user on a smaller
|
||||||
|
// number than the tile they clicked.
|
||||||
|
const statuses = (params.status ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
||||||
|
// Derived from the dates, not read off employees.status — see
|
||||||
|
// lib/employee-status-filter.ts for why the two can disagree.
|
||||||
|
query = applyDerivedStatusFilter(query, statuses, todayIso());
|
||||||
if (params.location) query = query.eq("location_id", params.location);
|
if (params.location) query = query.eq("location_id", params.location);
|
||||||
|
|
||||||
const { data: employeesData, count } = await query;
|
const { data: employeesData, count } = await query;
|
||||||
@@ -70,17 +82,17 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
|
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
|
||||||
|
|
||||||
<div className="overflow-x-auto rounded border border-border bg-white">
|
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||||
<table className="w-full min-w-[800px] text-sm">
|
<table className="w-full min-w-[800px] text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-surface text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||||
<th className="px-4 py-3">Mitarbeiter:in</th>
|
<th className="px-4 py-2.5">Mitarbeiter:in</th>
|
||||||
<th className="px-4 py-3">Pers.-Nr.</th>
|
<th className="px-4 py-2.5">Pers.-Nr.</th>
|
||||||
<th className="px-4 py-3">Bereich/Team</th>
|
<th className="px-4 py-2.5">Bereich/Team</th>
|
||||||
<th className="px-4 py-3">Standort</th>
|
<th className="px-4 py-2.5">Standort</th>
|
||||||
<th className="px-4 py-3">Eintritt</th>
|
<th className="px-4 py-2.5">Eintritt</th>
|
||||||
<th className="px-4 py-3">Beschäftigung</th>
|
<th className="px-4 py-2.5">Beschäftigung</th>
|
||||||
<th className="px-4 py-3">Status</th>
|
<th className="px-4 py-2.5">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -88,29 +100,36 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
|||||||
const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
|
const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
|
||||||
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
|
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
|
||||||
return (
|
return (
|
||||||
<tr key={e.id} className="border-b border-border last:border-0 hover:bg-surface">
|
// border-subtle between rows: the full-strength border made
|
||||||
<td className="px-4 py-3">
|
// an 800-row table read as a grid rather than a list.
|
||||||
<Link href={`/employees/${e.id}`} className="flex items-center gap-3">
|
<tr key={e.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Link
|
||||||
|
href={`/employees/${e.id}`}
|
||||||
|
className="flex items-center gap-3 rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||||||
|
>
|
||||||
<Avatar firstName={e.first_name} lastName={e.last_name} />
|
<Avatar firstName={e.first_name} lastName={e.last_name} />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="font-semibold text-ink">
|
<div className="truncate font-semibold text-ink">
|
||||||
{e.first_name} {e.last_name}
|
{e.first_name} {e.last_name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-ink-muted">{e.job_title}</div>
|
<div className="truncate text-xs text-ink-muted">{e.job_title}</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-ink-body">{e.personnel_number}</td>
|
{/* tabular-nums keeps the numeric columns aligned down the
|
||||||
<td className="px-4 py-3 text-ink-body">
|
page instead of jittering per row. */}
|
||||||
|
<td className="px-4 py-2.5 tabular-nums text-ink-body">{e.personnel_number}</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink-body">
|
||||||
<div>{division?.name ?? "–"}</div>
|
<div>{division?.name ?? "–"}</div>
|
||||||
<div className="text-xs text-ink-muted">{team?.name ?? "–"}</div>
|
<div className="text-xs text-ink-muted">{team?.name ?? "–"}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-ink-body">{location?.name ?? "–"}</td>
|
<td className="px-4 py-2.5 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-2.5 tabular-nums text-ink-body">{fmtDate(e.entry_date)}</td>
|
||||||
<td className="px-4 py-3 text-ink-body">
|
<td className="px-4 py-2.5 text-ink-body">
|
||||||
{e.employment_type} · {e.weekly_hours}h
|
{e.employment_type} · <span className="tabular-nums">{e.weekly_hours}h</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-2.5">
|
||||||
<StatusChip status={e.status} entryDate={e.entry_date} />
|
<StatusChip status={e.status} entryDate={e.entry_date} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
||||||
|
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
||||||
import { actionBadgeStyle } from "@/lib/colors";
|
import { actionBadgeStyle } from "@/lib/colors";
|
||||||
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
|
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
|
||||||
|
import { deriveStatusAsOf } from "@/lib/reports";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { createClient } from "@/lib/supabase/server";
|
||||||
import { fetchAllRows } from "@/lib/supabase/query";
|
import { fetchAllRows } from "@/lib/supabase/query";
|
||||||
|
|
||||||
const TONE_TEXT: Record<string, string> = {
|
// Each KPI carries a colour already; the accent bar repeats it in a second
|
||||||
default: "text-ink",
|
// channel so the tiles are scannable as a row rather than six identical
|
||||||
success: "text-success-text",
|
// boxes, and so the meaning does not rest on hue alone.
|
||||||
danger: "text-danger-text",
|
const TONE: Record<string, { text: string; bar: string }> = {
|
||||||
warning: "text-warning-text",
|
default: { text: "text-ink", bar: "bg-ink-muted" },
|
||||||
brand: "text-brand-700",
|
success: { text: "text-success-text", bar: "bg-success-text" },
|
||||||
|
danger: { text: "text-danger-text", bar: "bg-danger-text" },
|
||||||
|
warning: { text: "text-warning-text", bar: "bg-warning-text" },
|
||||||
|
brand: { text: "text-brand-700", bar: "bg-brand-500" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOT_STYLES: Record<string, string> = {
|
const DOT_STYLES: Record<string, string> = {
|
||||||
@@ -52,36 +58,50 @@ export default async function DashboardPage() {
|
|||||||
const yearEnd = `${year}-12-31`;
|
const yearEnd = `${year}-12-31`;
|
||||||
const in60Iso = addDaysIso(today, 60);
|
const in60Iso = addDaysIso(today, 60);
|
||||||
|
|
||||||
|
// Headcount, FTE, Karenz and the division bars all come from one full read
|
||||||
|
// and the *derived* status, not from the `employees.status` column.
|
||||||
|
//
|
||||||
|
// That column only ever reflects what the last mutation or cron run wrote,
|
||||||
|
// while every report derives status from entry/exit/karenz dates — so a
|
||||||
|
// planned hire whose start date has passed, or a Karenz that ended without
|
||||||
|
// anyone recording the return, made the dashboard and the Berichte page
|
||||||
|
// disagree about the same headcount. Same derivation, same numbers.
|
||||||
|
// It also replaces four separate count queries with one.
|
||||||
const [
|
const [
|
||||||
activeCountRes,
|
staffRows,
|
||||||
karenzCountRes,
|
|
||||||
hiresYtdRes,
|
hiresYtdRes,
|
||||||
exitsYtdRes,
|
exitsYtdRes,
|
||||||
openPositionsRes,
|
openPositionsRes,
|
||||||
fteRows,
|
|
||||||
divisionsRes,
|
divisionsRes,
|
||||||
headcountRows,
|
|
||||||
upcomingHiresRes,
|
upcomingHiresRes,
|
||||||
upcomingExitsRes,
|
upcomingExitsRes,
|
||||||
upcomingReturnsRes,
|
upcomingReturnsRes,
|
||||||
historyRes,
|
historyRes,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
supabase.from("employees").select("id", { count: "exact", head: true }).in("status", ["Aktiv", "Karenz"]),
|
fetchAllRows(() =>
|
||||||
supabase.from("employees").select("id", { count: "exact", head: true }).eq("status", "Karenz"),
|
|
||||||
supabase
|
supabase
|
||||||
.from("employees")
|
.from("employees")
|
||||||
.select("id", { count: "exact", head: true })
|
.select("weekly_hours, division_id, entry_date, exit_date, karenz_start_date, karenz_return_date")
|
||||||
.gte("entry_date", yearStart)
|
.order("id")
|
||||||
.lte("entry_date", yearEnd),
|
),
|
||||||
|
// Entries/exits count history events, which is what the linked report
|
||||||
|
// counts too. `entry_date` would also sweep up rehires, whose event is
|
||||||
|
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
||||||
|
// different numbers for the same year.
|
||||||
supabase
|
supabase
|
||||||
.from("employees")
|
.from("employee_history")
|
||||||
.select("id", { count: "exact", head: true })
|
.select("id", { count: "exact", head: true })
|
||||||
.gte("exit_date", yearStart)
|
.in("event_type", ["Eintritt", "Wiedereintritt"])
|
||||||
.lte("exit_date", yearEnd),
|
.gte("event_date", yearStart)
|
||||||
|
.lte("event_date", yearEnd),
|
||||||
|
supabase
|
||||||
|
.from("employee_history")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.eq("event_type", "Austritt")
|
||||||
|
.gte("event_date", yearStart)
|
||||||
|
.lte("event_date", yearEnd),
|
||||||
supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
|
supabase.from("positions").select("id", { count: "exact", head: true }).eq("status", "open"),
|
||||||
fetchAllRows(() => supabase.from("employees").select("weekly_hours").in("status", ["Aktiv", "Karenz"]).order("id")),
|
|
||||||
supabase.from("divisions").select("id, name"),
|
supabase.from("divisions").select("id, name"),
|
||||||
fetchAllRows(() => supabase.from("employees").select("division_id").in("status", ["Aktiv", "Karenz"]).order("id")),
|
|
||||||
supabase
|
supabase
|
||||||
.from("employees")
|
.from("employees")
|
||||||
.select("id, first_name, last_name, entry_date")
|
.select("id, first_name, last_name, entry_date")
|
||||||
@@ -109,10 +129,22 @@ export default async function DashboardPage() {
|
|||||||
.limit(10),
|
.limit(10),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const fte = fteRows.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
|
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
||||||
|
// active, and is counted by its own tile instead. FTE follows the same
|
||||||
|
// set: Karenz contributes no capacity, so including it would overstate
|
||||||
|
// what the company can actually staff.
|
||||||
|
//
|
||||||
|
// Note this is narrower than DEFAULT_STATUSES in lib/reports (Aktiv +
|
||||||
|
// Karenz), which still governs what the Berichte page shows when no
|
||||||
|
// status filter is chosen.
|
||||||
|
const statusOf = (row: (typeof staffRows)[number]) => deriveStatusAsOf(row, today);
|
||||||
|
const activeStaff = staffRows.filter((row) => statusOf(row) === "Aktiv");
|
||||||
|
const activeCount = activeStaff.length;
|
||||||
|
const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length;
|
||||||
|
const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
|
||||||
|
|
||||||
const headcountByDivision = new Map<string, number>();
|
const headcountByDivision = new Map<string, number>();
|
||||||
for (const row of headcountRows) {
|
for (const row of activeStaff) {
|
||||||
if (!row.division_id) continue;
|
if (!row.division_id) continue;
|
||||||
headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
|
headcountByDivision.set(row.division_id, (headcountByDivision.get(row.division_id) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
@@ -151,85 +183,131 @@ export default async function DashboardPage() {
|
|||||||
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
: { 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 employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
||||||
|
|
||||||
|
// Each tile links to the view that shows what it counts, with the filters
|
||||||
|
// pre-applied.
|
||||||
|
//
|
||||||
|
// Two of them cannot match exactly, and it is worth knowing which: the
|
||||||
|
// headcount tiles filter `employees` and their targets filter the same
|
||||||
|
// table, so those agree. Eintritte/Austritte count `employees.entry_date`
|
||||||
|
// / `exit_date`, while the events report counts `employee_history` rows —
|
||||||
|
// and rehire_employee sets entry_date but logs the event as
|
||||||
|
// 'Wiedereintritt'. A year with rehires therefore shows a slightly higher
|
||||||
|
// number on the tile than in the linked report.
|
||||||
const kpis = [
|
const kpis = [
|
||||||
{ label: "Aktive Mitarbeiter:innen", value: activeCountRes.count ?? 0, tone: "default" },
|
{
|
||||||
{ label: "FTE", value: fte.toFixed(1), tone: "default" },
|
label: "Aktive Mitarbeiter:innen",
|
||||||
{ label: "Eintritte (Jahr)", value: hiresYtdRes.count ?? 0, tone: "success" },
|
value: activeCount,
|
||||||
{ label: "Austritte (Jahr)", value: exitsYtdRes.count ?? 0, tone: "danger" },
|
tone: "default",
|
||||||
{ label: "In Karenz", value: karenzCountRes.count ?? 0, tone: "warning" },
|
href: "/employees?status=Aktiv",
|
||||||
{ label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand" },
|
},
|
||||||
|
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
||||||
|
{
|
||||||
|
label: "Eintritte (Jahr)",
|
||||||
|
value: hiresYtdRes.count ?? 0,
|
||||||
|
tone: "success",
|
||||||
|
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Austritte (Jahr)",
|
||||||
|
value: exitsYtdRes.count ?? 0,
|
||||||
|
tone: "danger",
|
||||||
|
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||||
|
},
|
||||||
|
{ label: "In Karenz", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
||||||
|
{ label: "Offene Positionen", value: openPositionsRes.count ?? 0, tone: "brand", href: "/positions" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{drafts && drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
{drafts && drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
||||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||||
{kpis.map((kpi) => (
|
{kpis.map((kpi) => (
|
||||||
<div key={kpi.label} className="rounded border border-border bg-white p-4">
|
<Link
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-ink-muted">{kpi.label}</div>
|
key={kpi.label}
|
||||||
<div className={`mt-2 text-2xl font-extrabold ${TONE_TEXT[kpi.tone]}`}>{kpi.value}</div>
|
href={kpi.href}
|
||||||
|
className={`${CARD_CLASS} group relative overflow-hidden p-4 pl-5 transition-shadow hover:shadow-[var(--shadow-card-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500`}
|
||||||
|
>
|
||||||
|
<span className={`absolute inset-y-0 left-0 w-1 ${TONE[kpi.tone].bar}`} aria-hidden />
|
||||||
|
{/* Number first in the visual order: it is what the eye is
|
||||||
|
looking for, and the label only qualifies it. */}
|
||||||
|
<div className={`text-3xl font-extrabold leading-none tabular-nums ${TONE[kpi.tone].text}`}>{kpi.value}</div>
|
||||||
|
<div className="mt-1.5 flex items-center gap-1 text-xs font-semibold leading-tight text-ink-muted">
|
||||||
|
{kpi.label}
|
||||||
|
<ChevronRight className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
<div className="rounded border border-border bg-white p-4">
|
<Card>
|
||||||
<h2 className="mb-3 text-sm font-bold text-ink">Headcount nach Bereich</h2>
|
{/* Aktive, not headcount: the bars count the same set as the tile
|
||||||
<div className="flex flex-col gap-2">
|
above them, which excludes Karenz. */}
|
||||||
|
<CardTitle className="mb-3">Aktive nach Bereich</CardTitle>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
{divisionBars.map((d) => (
|
{divisionBars.map((d) => (
|
||||||
<div key={d.name}>
|
<div key={d.name}>
|
||||||
<div className="mb-0.5 flex justify-between text-xs text-ink-body">
|
<div className="mb-1 flex justify-between text-xs">
|
||||||
<span>{d.name}</span>
|
<span className="text-ink-body">{d.name}</span>
|
||||||
<span className="font-semibold">{d.count}</span>
|
<span className="font-semibold tabular-nums text-ink">{d.count}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-2 rounded bg-surface">
|
{/* Rounded ends and a minimum width so the smallest division
|
||||||
<div className="h-2 rounded bg-brand-500" style={{ width: `${(d.count / maxDivisionCount) * 100}%` }} />
|
still reads as a bar rather than a stray pixel. */}
|
||||||
|
<div className="h-1.5 overflow-hidden rounded-full bg-surface">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-brand-500"
|
||||||
|
style={{ width: `${Math.max(2, (d.count / maxDivisionCount) * 100)}%` }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{divisionBars.length === 0 && <p className="text-sm text-ink-muted">Keine Daten vorhanden.</p>}
|
{divisionBars.length === 0 && <p className="text-sm text-ink-muted">Keine Daten vorhanden.</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className="rounded border border-border bg-white p-4">
|
<Card>
|
||||||
<h2 className="mb-3 text-sm font-bold text-ink">Anstehend (60 Tage)</h2>
|
<CardTitle className="mb-1">Anstehend (60 Tage)</CardTitle>
|
||||||
<ul className="flex flex-col divide-y divide-border">
|
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||||
{upcoming.map((item) => (
|
{upcoming.map((item) => (
|
||||||
<li key={`${item.kind}-${item.id}`}>
|
<li key={`${item.kind}-${item.id}`}>
|
||||||
<Link
|
<Link
|
||||||
href={`/employees/${item.id}`}
|
href={`/employees/${item.id}`}
|
||||||
className="flex items-center justify-between py-2 text-sm hover:text-brand-700"
|
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
|
||||||
>
|
>
|
||||||
<span>
|
<span className="min-w-0">
|
||||||
<span className="font-semibold text-ink">{item.label}</span>
|
<span className="block truncate font-semibold text-ink">{item.label}</span>
|
||||||
<span className="ml-2 text-xs text-ink-muted">{KIND_LABEL[item.kind]}</span>
|
<span className="text-xs text-ink-muted">{KIND_LABEL[item.kind]}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-ink-muted">{fmtDate(item.date)}</span>
|
<span className="shrink-0 text-xs font-semibold tabular-nums text-ink-muted">{fmtDate(item.date)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{upcoming.length === 0 && <p className="py-2 text-sm text-ink-muted">Keine anstehenden Ereignisse.</p>}
|
{upcoming.length === 0 && <p className="py-2 text-sm text-ink-muted">Keine anstehenden Ereignisse.</p>}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className="rounded border border-border bg-white p-4">
|
<Card>
|
||||||
<h2 className="mb-3 text-sm font-bold text-ink">Letzte Aktivitäten</h2>
|
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
||||||
<ul className="flex flex-col divide-y divide-border">
|
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||||
{(historyRes.data ?? []).map((h) => (
|
{(historyRes.data ?? []).map((h) => (
|
||||||
<li key={h.id} className="py-2">
|
<li key={h.id} className="flex gap-2.5 py-2.5">
|
||||||
<div className="flex items-center gap-2">
|
{/* Dot aligned to the first line of text, not centred on the
|
||||||
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} />
|
whole row, so it stays put as descriptions wrap. */}
|
||||||
|
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<span className="text-sm font-semibold text-ink">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span>
|
<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)}`}>
|
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||||
{h.event_type}
|
{h.event_type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-ink-muted">{h.description}</p>
|
<p className="mt-0.5 text-xs leading-relaxed text-ink-muted">{h.description}</p>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,7 +34,22 @@
|
|||||||
--color-purple-bg: #f0ecf7;
|
--color-purple-bg: #f0ecf7;
|
||||||
--color-purple-text: #5c2e91;
|
--color-purple-text: #5c2e91;
|
||||||
|
|
||||||
--radius: 8px;
|
/* A bare `--radius` in Tailwind v4 collapses the whole scale onto one
|
||||||
|
value — `rounded` and `rounded-lg` both came out at 8px, so a chip, an
|
||||||
|
input and a card could not be told apart. Named steps restore the
|
||||||
|
gradation; 8px stays the default so nothing shifts unintentionally. */
|
||||||
|
--radius-sm: 4px;
|
||||||
|
--radius: 6px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
|
||||||
|
/* Warm, brand-tinted rather than neutral grey: a pure black shadow over
|
||||||
|
the pink surface reads as dirt. Kept shallow — this is a data-dense
|
||||||
|
admin UI, the depth only has to separate a card from the page. */
|
||||||
|
--shadow-card: 0 1px 2px rgb(107 33 71 / 0.04), 0 1px 3px rgb(107 33 71 / 0.06);
|
||||||
|
--shadow-card-hover: 0 2px 4px rgb(107 33 71 / 0.06), 0 4px 12px rgb(107 33 71 / 0.08);
|
||||||
|
--shadow-overlay: 0 8px 32px rgb(45 28 38 / 0.16);
|
||||||
|
|
||||||
--font-sans: var(--font-nunito), system-ui, sans-serif;
|
--font-sans: var(--font-nunito), system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,16 @@ type EmployeeFiltersProps = {
|
|||||||
locations: { id: string; name: string }[];
|
locations: { id: string; name: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const;
|
// Aktiv and Karenz are separate statuses — somebody on Karenz is employed
|
||||||
|
// but not active. The combined entry is offered explicitly, and named after
|
||||||
|
// the two statuses it selects rather than calling the pair "aktiv".
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: "Aktiv", label: "Aktiv" },
|
||||||
|
{ value: "Karenz", label: "Karenz" },
|
||||||
|
{ value: "Aktiv,Karenz", label: "Aktiv + Karenz (beschäftigt)" },
|
||||||
|
{ value: "Geplant", label: "Geplant" },
|
||||||
|
{ value: "Ausgetreten", label: "Ausgetreten" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) {
|
export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -66,8 +75,8 @@ export function EmployeeFilters({ divisions, locations }: EmployeeFiltersProps)
|
|||||||
>
|
>
|
||||||
<option value="">Alle Status</option>
|
<option value="">Alle Status</option>
|
||||||
{STATUS_OPTIONS.map((s) => (
|
{STATUS_OPTIONS.map((s) => (
|
||||||
<option key={s} value={s}>
|
<option key={s.value} value={s.value}>
|
||||||
{s}
|
{s.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function NotesBell({ notes }: { notes: OpenNote[] }) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute right-0 z-20 mt-2 max-h-[28rem] w-96 overflow-y-auto rounded border border-border bg-white shadow-lg">
|
<div className="absolute right-0 z-20 mt-2 max-h-[28rem] w-96 overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]">
|
||||||
<div className="border-b border-border px-4 py-2.5 text-xs font-bold uppercase tracking-wide text-ink-muted">Meine Notizen ({notes.length})</div>
|
<div className="border-b border-border px-4 py-2.5 text-xs font-bold uppercase tracking-wide text-ink-muted">Meine Notizen ({notes.length})</div>
|
||||||
{notes.length === 0 ? (
|
{notes.length === 0 ? (
|
||||||
<p className="px-4 py-6 text-center text-sm text-ink-muted">Keine offenen Notizen.</p>
|
<p className="px-4 py-6 text-center text-sm text-ink-muted">Keine offenen Notizen.</p>
|
||||||
|
|||||||
@@ -18,8 +18,13 @@ export function Avatar({ firstName, lastName, color, size = "md" }: AvatarProps)
|
|||||||
const bg = color ?? avatarColorFor(`${firstName}${lastName}`);
|
const bg = color ?? avatarColorFor(`${firstName}${lastName}`);
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`inline-flex shrink-0 items-center justify-center rounded-full font-bold text-white ${SIZE_CLASSES[size]}`}
|
// A white ring separates adjacent avatars in the org chart and lets a
|
||||||
|
// strongly coloured one sit on a tinted row without vibrating against
|
||||||
|
// it. aria-hidden because the name is always rendered next to it —
|
||||||
|
// otherwise a screen reader reads the initials and then the name again.
|
||||||
|
className={`inline-flex shrink-0 select-none items-center justify-center rounded-full font-bold leading-none text-white ring-2 ring-white ${SIZE_CLASSES[size]}`}
|
||||||
style={{ backgroundColor: bg }}
|
style={{ backgroundColor: bg }}
|
||||||
|
aria-hidden
|
||||||
>
|
>
|
||||||
{initials(firstName, lastName)}
|
{initials(firstName, lastName)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
32
components/ui/Card.tsx
Normal file
32
components/ui/Card.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
// The same "rounded border border-border bg-white p-4" appeared at 26 call
|
||||||
|
// sites, all of them flat: a 1px border and nothing else, so a card sat on
|
||||||
|
// the page with no separation from it. Collecting them here is what makes it
|
||||||
|
// possible to give the whole app depth by changing one line.
|
||||||
|
|
||||||
|
export const CARD_CLASS = "rounded-md border border-border bg-white shadow-[var(--shadow-card)]";
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
padding = "md",
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
/** `none` for cards that manage their own inner spacing (tables, lists). */
|
||||||
|
padding?: "none" | "sm" | "md" | "lg";
|
||||||
|
}) {
|
||||||
|
const pad = { none: "", sm: "p-3", md: "p-4", lg: "p-6" }[padding];
|
||||||
|
return <div className={`${CARD_CLASS} ${pad} ${className}`}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Section titles were all `text-sm font-bold text-ink` — the same weight and
|
||||||
|
* size as emphasised body text, so headings did not read as a level of their
|
||||||
|
* own. Slightly smaller, letter-spaced and muted separates them without
|
||||||
|
* shouting.
|
||||||
|
*/
|
||||||
|
export function CardTitle({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||||
|
return <h2 className={`text-xs font-bold uppercase tracking-wider text-ink-muted ${className}`}>{children}</h2>;
|
||||||
|
}
|
||||||
@@ -132,7 +132,7 @@ export function CountryPicker({
|
|||||||
ref={listRef}
|
ref={listRef}
|
||||||
id={listId}
|
id={listId}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded border border-border bg-white shadow-lg"
|
className="absolute z-20 mt-1 max-h-56 w-full overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]"
|
||||||
>
|
>
|
||||||
{filtered.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
|
{filtered.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
|
||||||
{filtered.map((c, i) => (
|
{filtered.map((c, i) => (
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export function Lookup<T>({
|
|||||||
ref={listRef}
|
ref={listRef}
|
||||||
id={listId}
|
id={listId}
|
||||||
role="listbox"
|
role="listbox"
|
||||||
className="absolute z-20 mt-1 max-h-64 w-full overflow-y-auto rounded border border-border bg-white shadow-lg"
|
className="absolute z-20 mt-1 max-h-64 w-full overflow-y-auto rounded border border-border bg-white shadow-[var(--shadow-card-hover)]"
|
||||||
>
|
>
|
||||||
{loading && <div className="px-3 py-2 text-sm text-ink-muted">Suche…</div>}
|
{loading && <div className="px-3 py-2 text-sm text-ink-muted">Suche…</div>}
|
||||||
{!loading && visibleResults.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
|
{!loading && visibleResults.length === 0 && <div className="px-3 py-2 text-sm text-ink-muted">Keine Treffer</div>}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function Modal({ open, onClose, title, children, footer, widthClassName =
|
|||||||
// the accessible name cannot drift from what is on screen.
|
// the accessible name cannot drift from what is on screen.
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={`flex max-h-[92dvh] w-full flex-col rounded-t-xl bg-white shadow-xl outline-none sm:max-h-[85dvh] sm:rounded ${widthClassName}`}
|
className={`flex max-h-[92dvh] w-full flex-col rounded-t-xl bg-white shadow-[var(--shadow-overlay)] outline-none sm:max-h-[85dvh] sm:rounded ${widthClassName}`}
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3 sm:px-6 sm:py-4">
|
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3 sm:px-6 sm:py-4">
|
||||||
<h2 id={titleId} className="text-lg font-bold text-ink">
|
<h2 id={titleId} className="text-lg font-bold text-ink">
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function SlideOver({ open, onClose, title, subtitle, children, footer }:
|
|||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby={titleId}
|
aria-labelledby={titleId}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white shadow-xl outline-none transition-transform duration-200 ${
|
className={`absolute right-0 top-0 flex h-dvh w-full max-w-md flex-col bg-white shadow-[var(--shadow-overlay)] outline-none transition-transform duration-200 ${
|
||||||
open ? "translate-x-0" : "translate-x-full"
|
open ? "translate-x-0" : "translate-x-full"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
76
lib/employee-status-filter.ts
Normal file
76
lib/employee-status-filter.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import type { EmploymentStatus } from "./supabase/types";
|
||||||
|
|
||||||
|
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
|
||||||
|
//
|
||||||
|
// The employee list is paged in the database, so it cannot derive status in
|
||||||
|
// JavaScript the way the reports and the dashboard do — it has to filter on
|
||||||
|
// the server. Filtering on the `employees.status` column instead was the
|
||||||
|
// reason a dashboard tile and the list it links to could disagree: that
|
||||||
|
// column holds whatever the last mutation or cron run wrote, while every
|
||||||
|
// other surface computes status from entry/exit/karenz dates.
|
||||||
|
//
|
||||||
|
// Kept deliberately close to deriveStatusAsOf, clause for clause:
|
||||||
|
//
|
||||||
|
// entry_date > asOf -> Geplant
|
||||||
|
// exit_date <= asOf -> Ausgetreten
|
||||||
|
// karenz window covers asOf -> Karenz
|
||||||
|
// otherwise -> Aktiv
|
||||||
|
//
|
||||||
|
// tests/integration/employee-status-filter.test.ts asserts the two agree
|
||||||
|
// against a real database, which is the only place that can prove it.
|
||||||
|
|
||||||
|
type Filterable = {
|
||||||
|
gt: (column: string, value: string) => Filterable;
|
||||||
|
lte: (column: string, value: string) => Filterable;
|
||||||
|
gte: (column: string, value: string) => Filterable;
|
||||||
|
or: (filters: string) => Filterable;
|
||||||
|
is: (column: string, value: null) => Filterable;
|
||||||
|
not: (column: string, operator: string, value: null) => Filterable;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True once the person has started and has not left yet. */
|
||||||
|
function employed<Q extends Filterable>(query: Q, asOf: string): Q {
|
||||||
|
return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows a PostgREST query to the employees whose *derived* status on
|
||||||
|
* `asOf` is one of `statuses`. Only the combinations the UI offers are
|
||||||
|
* supported; anything else is left unfiltered rather than silently applying
|
||||||
|
* a wrong one.
|
||||||
|
*/
|
||||||
|
export function applyDerivedStatusFilter<Q extends Filterable>(query: Q, statuses: EmploymentStatus[], asOf: string): Q {
|
||||||
|
const wanted = new Set(statuses);
|
||||||
|
if (wanted.size === 0) return query;
|
||||||
|
|
||||||
|
// A single non-employed status is a straight date comparison.
|
||||||
|
if (wanted.size === 1 && wanted.has("Geplant")) return query.gt("entry_date", asOf) as Q;
|
||||||
|
if (wanted.size === 1 && wanted.has("Ausgetreten")) return query.not("exit_date", "is", null).lte("exit_date", asOf) as Q;
|
||||||
|
|
||||||
|
const wantsAktiv = wanted.has("Aktiv");
|
||||||
|
const wantsKarenz = wanted.has("Karenz");
|
||||||
|
|
||||||
|
if (wantsAktiv && wantsKarenz && wanted.size === 2) {
|
||||||
|
// Everyone employed today, whether or not they are on leave.
|
||||||
|
return employed(query, asOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
|
||||||
|
return employed(query, asOf)
|
||||||
|
.not("karenz_start_date", "is", null)
|
||||||
|
.lte("karenz_start_date", asOf)
|
||||||
|
.or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
|
||||||
|
// Employed but *not* inside a karenz window: either no start date, a
|
||||||
|
// start still ahead, or a return that has already happened.
|
||||||
|
return employed(query, asOf).or(
|
||||||
|
`karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}`
|
||||||
|
) as Q;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mixed selections spanning employed and non-employed states have no UI
|
||||||
|
// path today; filtering on a guess would be worse than not filtering.
|
||||||
|
return query;
|
||||||
|
}
|
||||||
79
tests/integration/employee-status-filter.test.ts
Normal file
79
tests/integration/employee-status-filter.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
|
||||||
|
import { todayIso } from "@/lib/format";
|
||||||
|
import { deriveStatusAsOf } from "@/lib/reports";
|
||||||
|
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||||
|
import { adminClient } from "./helpers";
|
||||||
|
|
||||||
|
// lib/employee-status-filter.ts is a SQL restatement of deriveStatusAsOf():
|
||||||
|
// the employee list pages in the database and cannot derive status in JS, so
|
||||||
|
// the same rule exists twice. Two copies of a rule drift, and the drift is
|
||||||
|
// invisible — a dashboard tile and the list it links to just quietly show
|
||||||
|
// different numbers.
|
||||||
|
//
|
||||||
|
// Only a real database can settle it, so this runs both over the whole
|
||||||
|
// seeded roster and demands the same set of ids.
|
||||||
|
describe("derived status filter matches deriveStatusAsOf", () => {
|
||||||
|
const asOf = todayIso();
|
||||||
|
|
||||||
|
async function idsFromDatabase(statuses: EmploymentStatus[]): Promise<Set<string>> {
|
||||||
|
const query = adminClient.from("employees").select("id");
|
||||||
|
const { data, error } = await applyDerivedStatusFilter(query, statuses, asOf);
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
return new Set((data ?? []).map((r) => r.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function idsFromDerivation(statuses: EmploymentStatus[]): Promise<Set<string>> {
|
||||||
|
const { data, error } = await adminClient
|
||||||
|
.from("employees")
|
||||||
|
.select("id, entry_date, exit_date, karenz_start_date, karenz_return_date");
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
return new Set((data ?? []).filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSameSet(statuses: EmploymentStatus[]) {
|
||||||
|
const [fromDb, fromJs] = await Promise.all([idsFromDatabase(statuses), idsFromDerivation(statuses)]);
|
||||||
|
|
||||||
|
const onlyInDb = [...fromDb].filter((id) => !fromJs.has(id));
|
||||||
|
const onlyInJs = [...fromJs].filter((id) => !fromDb.has(id));
|
||||||
|
|
||||||
|
expect({ statuses, onlyInDb: onlyInDb.slice(0, 5), onlyInJs: onlyInJs.slice(0, 5) }).toEqual({
|
||||||
|
statuses,
|
||||||
|
onlyInDb: [],
|
||||||
|
onlyInJs: [],
|
||||||
|
});
|
||||||
|
// Guards against a filter so narrow it matches nothing and passes
|
||||||
|
// trivially.
|
||||||
|
expect(fromJs.size).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("agrees for Aktiv + Karenz — the definition the dashboard headcount uses", async () => {
|
||||||
|
await expectSameSet(["Aktiv", "Karenz"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrees for Aktiv alone", async () => {
|
||||||
|
await expectSameSet(["Aktiv"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrees for Karenz alone", async () => {
|
||||||
|
await expectSameSet(["Karenz"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrees for Geplant", async () => {
|
||||||
|
await expectSameSet(["Geplant"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agrees for Ausgetreten", async () => {
|
||||||
|
await expectSameSet(["Ausgetreten"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns everyone when no status is selected", async () => {
|
||||||
|
const { count: total } = await adminClient.from("employees").select("id", { count: "exact", head: true });
|
||||||
|
const { count: filtered } = await applyDerivedStatusFilter(
|
||||||
|
adminClient.from("employees").select("id", { count: "exact", head: true }),
|
||||||
|
[],
|
||||||
|
asOf
|
||||||
|
);
|
||||||
|
expect(filtered).toBe(total);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user