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:
@@ -2,9 +2,11 @@ import Link from "next/link";
|
||||
import { Suspense } from "react";
|
||||
import { EmployeeFilters } from "@/components/employees/EmployeeFilters";
|
||||
import { Avatar } from "@/components/ui/Avatar";
|
||||
import { CARD_CLASS } from "@/components/ui/Card";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
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 { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
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.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);
|
||||
|
||||
const { data: employeesData, count } = await query;
|
||||
@@ -70,17 +82,17 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
</Suspense>
|
||||
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</p>
|
||||
|
||||
<div className="overflow-x-auto rounded border border-border bg-white">
|
||||
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||
<table className="w-full min-w-[800px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface text-left text-xs font-semibold uppercase tracking-wide text-ink-muted">
|
||||
<th className="px-4 py-3">Mitarbeiter:in</th>
|
||||
<th className="px-4 py-3">Pers.-Nr.</th>
|
||||
<th className="px-4 py-3">Bereich/Team</th>
|
||||
<th className="px-4 py-3">Standort</th>
|
||||
<th className="px-4 py-3">Eintritt</th>
|
||||
<th className="px-4 py-3">Beschäftigung</th>
|
||||
<th className="px-4 py-3">Status</th>
|
||||
<tr className="border-b border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||||
<th className="px-4 py-2.5">Mitarbeiter:in</th>
|
||||
<th className="px-4 py-2.5">Pers.-Nr.</th>
|
||||
<th className="px-4 py-2.5">Bereich/Team</th>
|
||||
<th className="px-4 py-2.5">Standort</th>
|
||||
<th className="px-4 py-2.5">Eintritt</th>
|
||||
<th className="px-4 py-2.5">Beschäftigung</th>
|
||||
<th className="px-4 py-2.5">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -88,29 +100,36 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
const { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
|
||||
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
|
||||
return (
|
||||
<tr key={e.id} className="border-b border-border last:border-0 hover:bg-surface">
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/employees/${e.id}`} className="flex items-center gap-3">
|
||||
// border-subtle between rows: the full-strength border made
|
||||
// an 800-row table read as a grid rather than a list.
|
||||
<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} />
|
||||
<div>
|
||||
<div className="font-semibold text-ink">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-ink">
|
||||
{e.first_name} {e.last_name}
|
||||
</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>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-ink-body">{e.personnel_number}</td>
|
||||
<td className="px-4 py-3 text-ink-body">
|
||||
{/* tabular-nums keeps the numeric columns aligned down the
|
||||
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 className="text-xs text-ink-muted">{team?.name ?? "–"}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-ink-body">{location?.name ?? "–"}</td>
|
||||
<td className="px-4 py-3 text-ink-body">{fmtDate(e.entry_date)}</td>
|
||||
<td className="px-4 py-3 text-ink-body">
|
||||
{e.employment_type} · {e.weekly_hours}h
|
||||
<td className="px-4 py-2.5 text-ink-body">{location?.name ?? "–"}</td>
|
||||
<td className="px-4 py-2.5 tabular-nums text-ink-body">{fmtDate(e.entry_date)}</td>
|
||||
<td className="px-4 py-2.5 text-ink-body">
|
||||
{e.employment_type} · <span className="tabular-nums">{e.weekly_hours}h</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-2.5">
|
||||
<StatusChip status={e.status} entryDate={e.entry_date} />
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user