Die neue Kachel "Aktives Dienstverhaeltnis" verlinkte auf
?status=Aktiv&status=Karenz. Die Mitarbeiterliste liest den Parameter aber
als *eine* Zeichenkette und trennt selbst an Kommas — zweimal uebergeben
macht Next daraus ein Array, und `.split(",")` lief dagegen. Sichtbar war
nur "Diese Ansicht konnte nicht geladen werden".
Die Kachel schreibt jetzt status=Aktiv,Karenz. Dazu glaettet die Seite alle
ihre Parameter: eine Adresse kommt nicht nur aus der eigenen Anwendung, sie
steht in Lesezeichen und in E-Mails, und ?q=a&q=b haette sie genauso
gefaellt.
Zwei Anmerkungen von Max:
* Die Reihenfolge der Wochentage wurde beim Speichern mitgenommen — "Mo,
Di" und "Di, Mo" waren zwei Werte fuer dieselbe Aussage. Da
change_employee_data die Arbeitstage als zusammengefuegte Zeichenkette
vergleicht, erzeugte jedes Nachsehen und Wiederherstellen eine
Vertragsaenderung in der Akte und einen Protokolleintrag — ueber nichts.
Jetzt sortiert gespeichert (lib/wochentage.ts, an einer Stelle statt in
vier Kopien), auch im Massenimport. Der Bestand richtet sich beim
naechsten Speichern von selbst.
* "Beguenstigt behindert" steht jetzt als eingerueckter Unterpunkt des
Kuendigungsschutzes statt als eigener Block daneben. In der Datenbank
bleiben es getrennte Felder, und das mit Absicht: eine Kopplung liesse
jede Korrektur am Personenkreis scheitern, solange der Grad noch
dransteht.
394 lines
18 KiB
TypeScript
394 lines
18 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;
|
||
|
||
/**
|
||
* 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>
|
||
|
||
<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 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>
|
||
);
|
||
}
|