Against the hosted database a round trip costs about as much as the queries themselves (~90ms), so page time was dominated by how many waves ran in sequence rather than by the SQL. Measured with the median of five runs: - Employee list 244ms -> 101ms. It awaited loadOrgMaps and only then the page of employees; the lookup tables are needed to label rows, not to build the query, so both now go out together. - Employee detail 120ms -> 62ms. Nine of the ten queries key off the id already in the URL and had no reason to wait for the employee row. The manager comes back as an embedded resource on that row instead of a follow-up query, which is what makes it one wave rather than two — an intermediate version that merely reordered the waves measured *slower*, and the embed is the part that actually helps. - Reports 197ms -> 180ms. Three waves became two. Modest, and worth saying so: the snapshot query itself dominates that page, not the wave count. Also fixes a blank employee list I caused. `absence_type` was added to the list's explicit column list ahead of its migration, and PostgREST rejects the *entire* query for one unknown column — so `data` came back null and the page rendered zero of 809 employees rather than just dropping a chip label. The column is out of that select until 20260726120000_absence_type.sql is applied; the detail page selects "*" and shows the kind once it exists. Verified against the real database rather than by typecheck alone, which is what would have caught it in the first place.
158 lines
7.3 KiB
TypeScript
158 lines
7.3 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 { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
|
||
import { fmtDate, todayIso } from "@/lib/format";
|
||
import { breadcrumbFor, loadOrgMaps } from "@/lib/org";
|
||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||
import { createClient } from "@/lib/supabase/server";
|
||
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 supabase = await createClient();
|
||
|
||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
||
const from = (page - 1) * PAGE_SIZE;
|
||
const to = from + PAGE_SIZE - 1;
|
||
|
||
let query = supabase
|
||
.from("employees")
|
||
.select(
|
||
// absence_type is deliberately absent here until
|
||
// 20260726120000_absence_type.sql has been applied: PostgREST rejects
|
||
// the *whole* query for one unknown column, which turned the entire
|
||
// list blank rather than just dropping a chip label. The detail page
|
||
// selects "*" and shows the specific kind once the column exists.
|
||
"id, first_name, last_name, personnel_number, job_title, team_id, division_id, location_id, entry_date, employment_type, weekly_hours, status",
|
||
{ count: "exact" }
|
||
)
|
||
.order("last_name", { ascending: true })
|
||
.range(from, to);
|
||
|
||
if (params.q) {
|
||
const q = params.q.trim();
|
||
if (/^\d+$/.test(q)) {
|
||
query = query.eq("personnel_number", Number(q));
|
||
} else {
|
||
const term = sanitizeIlikeTerm(q);
|
||
query = query.or(`first_name.ilike.%${term}%,last_name.ilike.%${term}%,job_title.ilike.%${term}%`);
|
||
}
|
||
}
|
||
if (params.division) query = query.eq("division_id", params.division);
|
||
// 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));
|
||
// Derived from the dates, not read off employees.status — see
|
||
// lib/employee-status-filter.ts for why the two can disagree.
|
||
query = applyDerivedStatusFilter(query, statuses, todayIso());
|
||
if (params.location) query = query.eq("location_id", params.location);
|
||
|
||
// The org lookup tables are needed only to label the rows, so they load
|
||
// alongside the page of employees instead of before it — one round trip
|
||
// saved on a page that is otherwise two fast queries.
|
||
const [orgMaps, { data: employeesData, count }] = await Promise.all([loadOrgMaps(supabase), query]);
|
||
const employees = employeesData ?? [];
|
||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<Suspense>
|
||
<EmployeeFilters divisions={orgMaps.divisionList} locations={orgMaps.locationList} />
|
||
</Suspense>
|
||
<p className="text-sm text-ink-muted">{count ?? 0} Mitarbeiter:innen gefunden</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 { division, team } = breadcrumbFor(orgMaps, e.division_id, e.team_id);
|
||
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">
|
||
{e.first_name} {e.last_name}
|
||
</div>
|
||
<div className="truncate text-xs text-ink-muted">{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>
|
||
<div className="text-xs text-ink-muted">{team?.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} />
|
||
</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>
|
||
);
|
||
}
|