Files
alpenwerk-hr/app/(app)/employees/page.tsx
Maximilian Stubhan 91b2b3406b Stop waiting on the network eleven times per page
The app got slower as pages grew, and the reason was not the queries. It
was their number.

A transaction is pinned to one connection, and a connection runs queries
one after another. Every Promise.all in a withUser block looked like
concurrency and was a queue. Measured against the real database: the
round trip is ~36 ms, ten trivial `select 1` over one connection take
343 ms, over ten connections 39 ms. Nothing here is slow — the whole
dashboard payload is under 200 kB, and every table is around a thousand
rows.

More connections is the wrong answer: the RLS session context is per
transaction, so parallel reads mean parallel transactions, and those
multiply the connections the database will grant. Fewer round trips
instead. Postgres will return each sub-select as its own JSON column of
one result.

Per page view, counting the transaction frame:

  shell (paid by every page)  10 → 4
  overview                    14 → 5
  employee file               14 → 7
  employee list                8 → 6

The overview plus its shell went from 24 round trips to 9 — about 860 ms
of pure waiting down to about 320 ms.

The one trap is documented where it bites: inside json_agg, Postgres
formats values itself and the driver's parsers (lib/db/pool.ts) never
see them. Dates, numerics and uuids come out identical; timestamptz does
not — "+00:00" where the driver gives "…Z". Timestamps are compared as
strings in lib/history.ts to decide what happened later, and those two
forms sort against each other wrongly. Every timestamptz in a bundled
query therefore goes through zeitstempel(), which was checked
character-for-character against the driver.

Four loaders moved out of their pages into lib/ so the number of round
trips can be measured without building a React tree, and so the new path
could be held against the old one field by field: same rows, same order,
same strings, for the overview and for four employee files chosen to
differ (with history, a chief, a planned entry, one with dependents).

withUser now counts the queries in each transaction and says so in
development past a threshold. Without that, this grows back: each new
tile brings its own query, and nobody notices until everybody does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:31:36 +02:00

295 lines
14 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 { 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/supabase/types";
const PAGE_SIZE = 15;
type SearchParams = {
q?: string;
division?: string;
status?: string;
location?: 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);
sp.set("page", String(page));
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 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",
])
// Nach id als zweitem Kriterium: bei gleichem Nachnamen wäre die
// Reihenfolge sonst unbestimmt, und dieselbe Person könnte auf zwei
// Seiten erscheinen oder auf keiner.
.orderBy("last_name")
.orderBy("id")
.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">
<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>
{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>
);
}