Files
alpenwerk-hr/app/(app)/employees/page.tsx
Maximilian Stubhan b87c8ad64c
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Remove Supabase
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:43:22 +02:00

302 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 { parseSortierung, richtung } 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;
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 A–Z, und Seite 2
// zeigte Namen, die auf Seite 1 schon standen.
if (params.sort) sp.set("sort", params.sort);
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 sortierung = parseSortierung(params.sort);
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. Die Kennung bleibt deshalb
// aufsteigend, auch wenn der Nachname sich dreht.
.orderBy("last_name", richtung(sortierung))
.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>
);
}