Files
alpenwerk-hr/app/(app)/employees/page.tsx
Andrei Laas 33d4582b30
All checks were successful
CI / Lint, Typen, Tests, Build (push) Successful in 11m33s
CI / Migrationen auf leerer Datenbank (push) Successful in 10m17s
In der Mitarbeiterliste nur die eigene Einheit zeigen, nicht den Bereich
Die Spalte fuehrte zwei Zeilen: den Bereich und darunter die Einheit der
Person. Beim Kunden heissen die Bereiche CEO, CFO, CSMO, CPO, COO, CHRO --
Rollenbezeichnungen, die ueber jedem Namen dasselbe wiederholten. Auf
seinen Wunsch bleibt nur die Einheit stehen; der ganze Weg von oben steht
weiterhin im title, denn eine Einheit wie "Shopleitung" sagt allein nicht,
welche gemeint ist.

Sortiert wird jetzt ebenfalls nach der Einheit. Bliebe der Bereich das
erste Kriterium, ordnete die Spalte nach einem Wert, den sie nicht mehr
anzeigt -- von aussen sieht das aus wie gar keine Sortierung. Der
rekursive Ausdruck aus 3e49be5 entfaellt damit; divisionOf bleibt in
Gebrauch, die Uebersicht gruppiert weiter nach Bereich.
2026-09-22 16:52:23 +02:00

399 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, loadOrgMaps, subtreeOf, unitOf, type OrgEb } from "@/lib/org";
import { loadPlacements } from "@/lib/placement";
import type { EmploymentStatus } from "@/lib/types";
const PAGE_SIZE = 15;
/**
* Die Parameter, nachdem sie geglättet sind — je einer, oder keiner.
*
* Was Next tatsächlich liefert, steht in RohParams: derselbe Name zweimal in
* der Adresse wird dort zu einem Array. Diese Seite hat das nicht erwartet
* und ist daran gescheitert — eine Kachel der Übersicht verwies auf
* `?status=Aktiv&status=Karenz`, und `params.status.split(",")` lief gegen
* ein Array. Sichtbar war davon nur „Diese Ansicht konnte nicht geladen
* werden" mit einer Fehlerkennung.
*
* Die Kachel ist berichtigt (sie schreibt jetzt `status=Aktiv,Karenz`), aber
* eine Adresse kommt nicht nur aus der eigenen Anwendung: sie steht in
* Lesezeichen, in Verknüpfungen, in E-Mails. Deshalb wird hier geglättet
* statt sich darauf zu verlassen, dass niemand zweimal denselben Namen
* schreibt.
*/
type SearchParams = {
q?: string;
division?: string;
status?: string;
location?: string;
sort?: string;
dir?: string;
page?: string;
};
type RohParams = Record<string, string | string[] | undefined>;
/** Der erste Wert eines Parameters — auch wenn er mehrfach in der Adresse steht. */
function einWert(wert: string | string[] | undefined): string | undefined {
return Array.isArray(wert) ? wert[0] : wert;
}
function glaetten(roh: RohParams): SearchParams {
return {
q: einWert(roh.q),
division: einWert(roh.division),
status: einWert(roh.status),
location: einWert(roh.location),
sort: einWert(roh.sort),
dir: einWert(roh.dir),
page: einWert(roh.page),
};
}
type EmployeesPageProps = {
searchParams: Promise<RohParams>;
};
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 = glaetten(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",
// Nicht `status`: der Chip leitet ab, wie der Filter es tut —
// siehe components/ui/StatusChip.tsx. Drei Spalten mehr auf
// fünfzehn Zeilen, keine zusätzliche Rundreise.
"exit_date",
"karenz_start_date",
"karenz_return_date",
"absence_type",
])
.$call((q) => sortiere(q, sortFeld, sortRichtung, today))
.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>
{/* Zur zweiten Achse siehe den Kommentar auf der Protokollseite: ohne
`overflow-y-hidden` stuft CSS sie auf `auto` hoch, und die Karte
bekommt eine senkrechte Leiste, die nichts bewirkt. */}
<div className={`overflow-x-auto overflow-y-hidden ${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 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>
{/* Nur die eigene Einheit, egal auf welcher Ebene sie hängt.
Darüber stand der Bereich; der Kunde wollte ihn weg, weil
seine Bereiche CEO, CFO, COO heissen und über jedem Namen
dasselbe wiederholten. Der ganze Weg von oben steht
weiterhin im `title` — eine Einheit wie „Shopleitung"
sagt allein nicht, welche gemeint ist. */}
<td
className="px-4 py-2.5 text-ink-body"
title={breadcrumbLabel(orgMaps, placement?.orgUnitId)}
>
{unit?.name ?? "–"}
</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 employee={e} asOf={today} />
</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>
);
}