Files
alpenwerk-hr/app/(app)/employees/page.tsx
Andrei Laas cf5ed5c6c6
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Successful in 11m17s
Vier Befunde, die still falsche Ergebnisse lieferten
C.08 -- die Suche mit Bindestrich fand nichts. Das Feld wird per translate an
"-/:.," in Leerzeichen zerlegt, die Eingabe aber nicht: "Mueller-Weiss" erzeugte
das Muster mueller-weiss%, waehrend im Heuhaufen "mueller weiss" stand. Ohne
Bindestrich fand man dieselbe Person. Die Trennzeichen stehen jetzt einmal in
lib/employee-search.ts und werden von beiden Seiten des Vergleichs benutzt.

C.09 -- der Einheitenfilter verlor jede Person mit vorgemerktem Austritt.
"Laufend" war valid_to is null, aber terminate_employee setzt das Ende schon
beim Erfassen, Monate vor dem Tag. Jetzt zaehlt auch, was noch laeuft
(valid_to > heute). Bewusst ohne valid_from <= heute: ein geplanter Eintritt
gehoert in die Liste, sonst fiele er aus dem Filter, obwohl der Status
"Geplant" ihn ausdruecklich fuehrt.

E.07 -- nur die Kostenstelle zu aendern war unmoeglich. update_position weist
einen Aufruf ohne Aenderung ab, und die Umkontierung lief erst bei dessen
Erfolg. Sie wird jetzt uebersprungen, wenn sich an den Stammangaben nichts
geaendert hat.

H.06 -- jedes Speichern erzeugte zusaetzlich "Wochenstunden 30.0 -> 30". Der
Vergleich laeuft ueber Text, die Spalte ist numeric(4,1), und das Formular
schickt 30. Der Kommentar an der Zeile nannte die Absicht richtig, nur reicht
::numeric dafuer nicht -- es muss auf die Genauigkeit der Spalte gehen.
2026-09-29 19:47:48 +02:00

410 lines
19 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, TRENNZEICHEN, TRENNZEICHEN_ERSATZ } 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")
// Laufend **oder noch bevorstehend**: `valid_to is null` allein
// liess jede Person mit vorgemerktem Austritt aus der Einheit
// verschwinden — terminate_employee setzt das Ende schon beim
// Erfassen, Monate vor dem Tag. Gemeldet im Test vom 29.09.
// (C.09). Das Ende ist ausschliessend, deshalb `>` und nicht
// `>=`. Kein `valid_from <= heute`: ein geplanter Eintritt
// gehört in die Liste, sonst fiele er aus dem Einheitenfilter,
// obwohl der Status „Geplant" ihn ausdrücklich führt.
.where((e) => e.or([e("a.valid_to", "is", null), e("a.valid_to", ">", today)]))
.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.
// Die Trennzeichen stehen in lib/employee-search.ts, weil die
// Eingabe an denselben zerlegt werden muss — standen sie nur hier,
// fand „Müller-Weiß" nichts (C.08).
const heuhaufen = nurNamen
? sql<string>`translate(lower(first_name || ' ' || last_name), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})`
: sql<string>`translate(lower(first_name || ' ' || last_name || ' ' || job_title), ${TRENNZEICHEN}, ${TRENNZEICHEN_ERSATZ})`;
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>
);
}