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>
302 lines
14 KiB
TypeScript
302 lines
14 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 { 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>
|
||
);
|
||
}
|