The dropdown is gone; each column header is now a link that sorts by that column, with an arrow showing the direction. Clicking the column already sorted reverses it; clicking a different one starts ascending again — going from "Eintritt, newest first" to "Name" should give you names from A, not inherit the previous direction. Names sort by surname and then forename, as asked. Both parts reverse together: turning only the surname would give Z-A across surnames but A-Z within each one, which is visible immediately among the fifteen Aigner. Three of the seven columns are not on the employee row. Bereich and Team hang off the position, Standort off a lookup table, so they are fetched as correlated subqueries rather than joins. That is not a style preference: the same filter chain produces the page *and* the count, and a join onto position_assignments would double every person who has held more than one position over time — the line above the list would read 1,203 for 867 people. Bereich is the level below the company, so it needs to walk up from the unit. No recursion: org_unit_type has exactly four levels, so two hops up cover it. Everything sorts `nulls last`, otherwise reversing the direction floats every person without a position or location to the top. The expressions live in lib/employee-sort.ts rather than in the page so the generated SQL can be read in a test — the failure mode here is silent, the list still shows fifteen rows, just the wrong ones. Eighteen tests, and the rules are mutation-checked: dropping the forename, dropping the id tiebreaker, dropping `nulls last`, sorting the location by its uuid, and shortening the Bereich walk each turn them red. Not seen in a browser: login goes through the company account and the database is unreachable. Typecheck, lint, 458 tests and the build are clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
354 lines
16 KiB
TypeScript
354 lines
16 KiB
TypeScript
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 { currentUserId } from "@/lib/auth/session";
|
||
import { sql, withUser } from "@/lib/db";
|
||
import { jsonArrayFrom, jsonObjectFrom } from "@/lib/db/json";
|
||
import { istPersonalnummer, suchMuster } from "@/lib/employee-search";
|
||
import {
|
||
SORTIERFELDER,
|
||
naechsteRichtung,
|
||
sortiere,
|
||
parseFeld,
|
||
parseRichtung,
|
||
type Richtung,
|
||
type Sortierfeld,
|
||
} from "@/lib/employee-sort";
|
||
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||
import { fmtDate, fmtName, todayIso } from "@/lib/format";
|
||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
|
||
import { loadPlacements } from "@/lib/placement";
|
||
import type { EmploymentStatus } from "@/lib/types";
|
||
|
||
const PAGE_SIZE = 15;
|
||
|
||
type SearchParams = {
|
||
q?: string;
|
||
division?: string;
|
||
status?: string;
|
||
location?: string;
|
||
sort?: string;
|
||
dir?: string;
|
||
page?: string;
|
||
};
|
||
|
||
type EmployeesPageProps = {
|
||
searchParams: Promise<SearchParams>;
|
||
};
|
||
|
||
function pageHref(params: SearchParams, page: number): string {
|
||
const sp = new URLSearchParams();
|
||
if (params.q) sp.set("q", params.q);
|
||
if (params.division) sp.set("division", params.division);
|
||
if (params.status) sp.set("status", params.status);
|
||
if (params.location) sp.set("location", params.location);
|
||
// Ohne das kippte die Liste beim Blättern zurück auf den Standard, und
|
||
// Seite 2 zeigte Namen, die auf Seite 1 schon standen.
|
||
if (params.sort) sp.set("sort", params.sort);
|
||
if (params.dir) sp.set("dir", params.dir);
|
||
sp.set("page", String(page));
|
||
return `/employees?${sp.toString()}`;
|
||
}
|
||
|
||
/**
|
||
* Die Adresse hinter einem Spaltenkopf.
|
||
*
|
||
* Ohne `page`: nach dem Umsortieren steht auf Seite 7 etwas völlig anderes
|
||
* als vorher. Wer sortiert, will von vorne anfangen.
|
||
*/
|
||
function sortHref(params: SearchParams, feld: Sortierfeld, aktuell: Sortierfeld, richtung: Richtung): string {
|
||
const sp = new URLSearchParams();
|
||
if (params.q) sp.set("q", params.q);
|
||
if (params.division) sp.set("division", params.division);
|
||
if (params.status) sp.set("status", params.status);
|
||
if (params.location) sp.set("location", params.location);
|
||
sp.set("sort", feld);
|
||
sp.set("dir", naechsteRichtung(aktuell, richtung, feld));
|
||
return `/employees?${sp.toString()}`;
|
||
}
|
||
|
||
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
||
const params = await searchParams;
|
||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||
const sortFeld = parseFeld(params.sort);
|
||
const sortRichtung = parseRichtung(params.dir);
|
||
const today = todayIso();
|
||
|
||
// Comma-separated, so a dashboard tile can link here with the same
|
||
// status set it counted rather than a narrower one.
|
||
const statuses = (params.status ?? "")
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
||
|
||
const { orgMaps, employees, count, placements, ueberPosition } = await withUser(await currentUserId(), async (tx) => {
|
||
// Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum.
|
||
// „Produktion" meint die Abteilungen und Teams darunter — in der Einheit
|
||
// selbst sitzt nur die Bereichsleitung.
|
||
const orgMaps = await loadOrgMaps(tx);
|
||
const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null;
|
||
|
||
// Eine Filterkette, zwei Abfragen: eine für die Seite, eine für die
|
||
// Gesamtzahl. Am direkten Zugang teilen sie sich denselben Aufbau —
|
||
// vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der
|
||
// API-Schicht einen bedingt zusammengesetzten Select-String nicht
|
||
// auflösen konnte.
|
||
// Der Ausdrucksbauer wird durchgereicht, damit dieselbe Filterkette
|
||
// einmal für die Seite und einmal für die Zählung in *einer* Abfrage
|
||
// stehen kann.
|
||
const base = (nurNamen: boolean, eb: OrgEb = tx as never) => {
|
||
let q = eb.selectFrom("employees");
|
||
|
||
if (unitFilter) {
|
||
// Nach Organisationseinheit gefiltert wird über die *laufende*
|
||
// Besetzung. Als EXISTS, damit eine Person nicht mehrfach erscheint,
|
||
// wenn sie über die Zeit mehrere Zuordnungen hatte.
|
||
const units = subtreeOf(orgMaps, unitFilter);
|
||
q = q.where((eb) =>
|
||
eb.exists(
|
||
eb
|
||
.selectFrom("position_assignments as a")
|
||
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
||
.select("a.id")
|
||
.whereRef("a.employee_id", "=", "employees.id")
|
||
.where("a.valid_to", "is", null)
|
||
.where("p.org_unit_id", "in", units)
|
||
)
|
||
);
|
||
}
|
||
|
||
if (params.q) {
|
||
const term = params.q.trim();
|
||
// `\d`, nicht `d`: der fehlende Backslash liess die Ziffernerkennung
|
||
// nie greifen — „1590" wurde als Name gesucht und fand nichts,
|
||
// während das Muster auf „ddd" ansprang.
|
||
if (istPersonalnummer(term)) {
|
||
q = q.where("personnel_number", "=", Number(term));
|
||
} else {
|
||
// ── Wie hier gesucht wird ──────────────────────────────────
|
||
//
|
||
// **Wortweise, Reihenfolge egal.** Jedes Wort muss treffen, aber
|
||
// nicht in einer bestimmten Ordnung: „Winkler micha" und „micha
|
||
// Winkler" führen beide zu Michaela Winkler. Am Stück gesucht stand
|
||
// „Michael Winkler" in keinem einzelnen Feld und ergab null Treffer,
|
||
// obwohl es vierzehn Winkler gibt.
|
||
//
|
||
// **Am Wortanfang, nicht mittendrin.** Als Teilzeichenkette traf ein
|
||
// „H" auf T-h-omas und Kat-h-arina — bei „Winkler H" kamen alle
|
||
// sieben Winkler zurück. Trennzeichen zählen als Wortgrenze, damit
|
||
// „dreher" auch „CNC-Dreher:in" findet.
|
||
//
|
||
// **Namen vor Positionen.** Die Position mitzudurchsuchen ist
|
||
// nützlich („dreher"), darf aber eine Namenssuche nicht verwässern:
|
||
// bei „Winkler M" tauchten sonst Karin Winkler (Montagemitarbeiterin)
|
||
// und Katharina Winkler (Maschinenbedienerin) auf, weil ihre
|
||
// Position mit M beginnt. Deshalb wird zuerst nur über die Namen
|
||
// gesucht; nur wenn das *nichts* findet, kommt die Position dazu.
|
||
// Eine feste Mindestlänge fürs Wort wäre die einfachere Regel, aber
|
||
// jede Grenze wäre geraten — diese hier ergibt sich aus den Daten.
|
||
//
|
||
// Der Trigramm-Index auf dem zusammengesetzten Namen greift bei
|
||
// diesem Ausdruck nicht mehr. Bei knapp neunhundert Zeilen liest
|
||
// Postgres die Tabelle in wenigen Millisekunden; ein Index auf
|
||
// demselben Ausdruck holt das zurück, sobald das nicht mehr stimmt.
|
||
const heuhaufen = nurNamen
|
||
? sql<string>`translate(lower(first_name || ' ' || last_name), '-/:.,', ' ')`
|
||
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), '-/:.,', ' ')`;
|
||
q = q.where((eb) =>
|
||
eb.and(
|
||
// Als Parameter gebunden, nicht in die Abfrage geschrieben.
|
||
suchMuster(term).map(([amAnfang, nachLeerzeichen]) =>
|
||
eb.or([eb(heuhaufen, "like", amAnfang), eb(heuhaufen, "like", nachLeerzeichen)])
|
||
)
|
||
)
|
||
);
|
||
}
|
||
}
|
||
|
||
// Derived from the dates, not read off employees.status — see
|
||
// lib/employee-status-filter.ts for why the two can disagree.
|
||
if (statuses.length > 0) {
|
||
q = q.where((eb) => derivedStatusFilter(eb, statuses, today) ?? eb.val(true));
|
||
}
|
||
|
||
if (params.location) q = q.where("location_id", "=", params.location);
|
||
return q;
|
||
};
|
||
|
||
// Erst nachsehen, ob die Namen allein etwas hergeben. Nur wenn nicht,
|
||
// wird die Position mitgesucht — eine zusätzliche, sehr kleine Abfrage,
|
||
// und nur bei einer Textsuche.
|
||
const sucheNachNamen = Boolean(params.q) && !istPersonalnummer(params.q!.trim());
|
||
const namensTreffer = sucheNachNamen
|
||
? Number((await base(true).select(({ fn }) => fn.countAll<string>().as("anzahl")).executeTakeFirst())?.anzahl ?? 0)
|
||
: 0;
|
||
const nurNamen = sucheNachNamen && namensTreffer > 0;
|
||
|
||
// Seite und Gesamtzahl in *einer* Rundreise. Als Promise.all sah das nach
|
||
// Gleichzeitigkeit aus und war keine: eine Transaktion hängt an einer
|
||
// Verbindung, und darüber laufen Abfragen nacheinander (lib/db/json.ts).
|
||
const { rows, total } = await tx
|
||
.selectNoFrom((eb) => [
|
||
jsonArrayFrom(
|
||
base(nurNamen, eb)
|
||
.select([
|
||
"id",
|
||
"first_name",
|
||
"last_name",
|
||
"personnel_number",
|
||
"job_title",
|
||
"location_id",
|
||
"entry_date",
|
||
"employment_type",
|
||
"weekly_hours",
|
||
"status",
|
||
"absence_type",
|
||
])
|
||
.$call((q) => sortiere(q, sortFeld, sortRichtung))
|
||
.limit(PAGE_SIZE)
|
||
.offset((page - 1) * PAGE_SIZE)
|
||
).as("rows"),
|
||
jsonObjectFrom(base(nurNamen, eb).select(({ fn }) => fn.countAll<string>().as("anzahl"))).as("total"),
|
||
])
|
||
.executeTakeFirstOrThrow();
|
||
|
||
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
|
||
// Seite, nicht für den ganzen Bestand.
|
||
const placements = await loadPlacements(tx, { asOf: today, employeeIds: rows.map((e) => e.id) });
|
||
|
||
return {
|
||
orgMaps,
|
||
employees: rows,
|
||
count: Number(total?.anzahl ?? 0),
|
||
placements,
|
||
// Für den Hinweis über der Liste: wurde nach Namen gesucht, und hat es
|
||
// gereicht?
|
||
ueberPosition: sucheNachNamen && !nurNamen,
|
||
};
|
||
});
|
||
|
||
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<Suspense>
|
||
<EmployeeFilters units={orgMaps.unitList} depthOf={orgMaps.depthOf} locations={orgMaps.locationList} />
|
||
</Suspense>
|
||
<p className="text-sm text-ink-muted">
|
||
{count ?? 0} Mitarbeiter:innen gefunden
|
||
{/* Wenn kein Name passte, wurde nach der Position gesucht. Ohne diesen
|
||
Hinweis wirkt das Ergebnis, als hätte die Suche etwas erfunden. */}
|
||
{ueberPosition && (count ?? 0) > 0 && (
|
||
<span className="text-ink-muted"> · kein Namenstreffer, gesucht nach Position</span>
|
||
)}
|
||
</p>
|
||
|
||
<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-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||
{SORTIERFELDER.map((f) => {
|
||
const aktiv = f.value === sortFeld;
|
||
return (
|
||
// `aria-sort` sagt einem Screenreader, welche Spalte die
|
||
// Reihenfolge bestimmt und in welche Richtung — der Pfeil
|
||
// allein ist für ihn nicht da.
|
||
<th
|
||
key={f.value}
|
||
scope="col"
|
||
aria-sort={aktiv ? (sortRichtung === "asc" ? "ascending" : "descending") : "none"}
|
||
className="px-4 py-2.5"
|
||
>
|
||
<Link
|
||
href={sortHref(params, f.value, sortFeld, sortRichtung)}
|
||
// `title` nennt aus, was der Pfeil andeutet: was der
|
||
// nächste Klick tut, nicht was gerade gilt.
|
||
title={
|
||
aktiv && sortRichtung === "asc"
|
||
? `${f.label} absteigend sortieren`
|
||
: `${f.label} aufsteigend sortieren`
|
||
}
|
||
className={`group inline-flex items-center gap-1 rounded transition-colors hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500 ${
|
||
aktiv ? "text-ink" : ""
|
||
}`}
|
||
>
|
||
{f.label}
|
||
{/* Der Pfeil steht immer da, nur blass, solange die
|
||
Spalte nicht sortiert: sonst springt die Kopfzeile
|
||
beim Klicken um eine Pfeilbreite. */}
|
||
<span aria-hidden className={aktiv ? "text-brand-600" : "text-ink-muted/30 group-hover:text-ink-muted"}>
|
||
{aktiv && sortRichtung === "desc" ? "▼" : "▲"}
|
||
</span>
|
||
</Link>
|
||
</th>
|
||
);
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{employees.map((e) => {
|
||
const placement = placements.get(e.id);
|
||
const division = divisionOf(orgMaps, placement?.orgUnitId);
|
||
const unit = unitOf(orgMaps, placement?.orgUnitId);
|
||
const location = e.location_id ? orgMaps.locations.get(e.location_id) : undefined;
|
||
return (
|
||
// 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 className="min-w-0">
|
||
<div className="truncate font-semibold text-ink">
|
||
{fmtName(e.first_name, e.last_name)}
|
||
</div>
|
||
<div className="truncate text-xs text-ink-muted">{placement?.jobTitle ?? e.job_title}</div>
|
||
</div>
|
||
</Link>
|
||
</td>
|
||
{/* 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>
|
||
{/* Die eigene Einheit, egal auf welcher Ebene sie hängt —
|
||
eine Bereichsleitung sitzt am Bereich, nicht an einem
|
||
Team, und stand vorher deshalb ohne Zuordnung da. */}
|
||
<div className="text-xs text-ink-muted" title={breadcrumbLabel(orgMaps, placement?.orgUnitId)}>
|
||
{unit && unit.id !== division?.id ? unit.name : "–"}
|
||
</div>
|
||
</td>
|
||
<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-2.5">
|
||
<StatusChip status={e.status} entryDate={e.entry_date} absenceType={e.absence_type} />
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
{employees.length === 0 && (
|
||
<tr>
|
||
<td colSpan={7} className="px-4 py-8 text-center text-sm text-ink-muted">
|
||
Keine Mitarbeiter:innen gefunden.
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<Pagination page={page} totalPages={totalPages} hrefFor={(p) => pageHref(params, p)} label="Mitarbeiter:innen" />
|
||
</div>
|
||
);
|
||
}
|