Talk to PostgreSQL directly, and let the pooled connection forget
Zweiter Schritt weg von Supabase. Sämtliche 49 Lesezugriffe und alle
Mutationen laufen jetzt über lib/db statt über die REST-Schicht: Kysely auf
einem pg-Pool, jede Abfrage in einer Transaktion, in der zuerst
app.user_id gesetzt wird. Die Anmeldung hängt noch an GoTrue — sie liefert
die Kennung, die in withUser() geht. Damit war der Umbau in zwei Hälften
teilbar und die Anwendung durchgehend lauffähig.
Was dabei ersatzlos verschwindet:
- fetchAllRows. Es gab die Funktion nur, weil PostgREST jede Antwort bei
1000 Zeilen still abschneidet und ein Bericht dann leise falsch war.
Am direkten Zugang ist eine Abfrage eine Abfrage.
- sanitizeIlikeTerm samt Test. Sie entschärfte Zeichen, die in der
Filtersyntax strukturelle Bedeutung hatten; jetzt wird der Suchbegriff
als Parameter gebunden und ein Komma ist ein Komma. Die Lücke ist nicht
abgesichert, sondern weg.
- lib/supabase/admin.ts. Der Dienstschlüssel, der RLS aushebelte, hatte
genau einen Aufrufer — den nächtlichen Lauf. Der benutzt jetzt dieselbe
Rolle ohne BYPASSRLS und ruft eine SECURITY-DEFINER-Funktion auf, die
selbst prüft, was sie tut. Es gibt keinen privilegierten Zugang mehr.
Nebenbei besser geworden, weil der direkte Zugang es erlaubt:
- Eine Seite ist eine Transaktion. Das Layout etwa liest Profil,
Planstellen, Standorte, Entwürfe und Notizen auf einem einheitlichen
Lesestand statt in fünf unabhängigen Anfragen.
- Der Bereichsfilter der Mitarbeiterliste ist ein EXISTS statt einer
eingebetteten Ressource mit !inner — eine Person mit mehreren
Zuordnungen über die Zeit erschien dort mehrfach.
- Seitenweise Listen sortieren zusätzlich nach id. Bei gleichem Nachnamen
oder gleichem Zeitstempel war die Reihenfolge vorher unbestimmt, und
dieselbe Zeile konnte auf zwei Seiten erscheinen oder auf keiner.
- Angehörige werden in der Datenbank gezählt statt alle Zeilen zu holen.
- Namen an Ereigniszeilen kommen aus einem Join statt aus einem
Nachschlag, der ausserhalb der Transaktion lag.
Der Statusfilter ist mitgezogen: dieselbe Regel wie deriveStatusAsOf,
Klausel für Klausel, jetzt als Kysely-Ausdruck. Der Integrationstest, der
beide über den gesamten Bestand vergleicht, läuft weiter — mit eigener
Verbindung, denn geprüft wird die Bedingung, nicht die Berechtigung.
Zwei Fehler auf dem Weg, beide vom Typprüfer gefangen: apply_due_pending_
changes() nimmt kein Argument, wurde von callFunction aber mit jsonb
aufgerufen — Postgres hätte keine passende Signatur gefunden. Und der
Sicherheitstest lädt jetzt Module mit `import "server-only"`, was ausserhalb
der Server-Übersetzung wirft.
Typecheck, Lint, Build und 180 Tests sind grün. Ungeprüft bleibt der Lauf
gegen eine echte Datenbank — dafür fehlt eine DATABASE_URL.
This commit is contained in:
@@ -4,8 +4,8 @@ import { AuditFilters } from "@/components/audit/AuditFilters";
|
||||
import { CARD_CLASS } from "@/components/ui/Card";
|
||||
import { Pagination } from "@/components/ui/Pagination";
|
||||
import { actionBadgeStyle } from "@/lib/colors";
|
||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -34,33 +34,50 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
||||
|
||||
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
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("audit_log")
|
||||
.select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" })
|
||||
.order("occurred_at", { ascending: false })
|
||||
.range(from, to);
|
||||
const { entries, count } = await withUser(await currentUserId(), async (tx) => {
|
||||
const base = () => {
|
||||
let q = tx.selectFrom("audit_log");
|
||||
if (params.action) q = q.where("action", "=", params.action);
|
||||
if (params.q) {
|
||||
// Als Parameter gebunden statt in die Abfrage geschrieben: die
|
||||
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, haben
|
||||
// hier keine Bedeutung mehr.
|
||||
const like = `%${params.q.trim()}%`;
|
||||
q = q.where((eb) =>
|
||||
eb.or([eb("target_label", "ilike", like), eb("details", "ilike", like), eb("actor_name", "ilike", like)])
|
||||
);
|
||||
}
|
||||
return q;
|
||||
};
|
||||
|
||||
if (params.action) query = query.eq("action", params.action);
|
||||
if (params.q) {
|
||||
const q = sanitizeIlikeTerm(params.q.trim());
|
||||
query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`);
|
||||
}
|
||||
const [entries, total] = await Promise.all([
|
||||
base()
|
||||
.select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details"])
|
||||
// Nach id als zweitem Kriterium: bei gleichem Zeitstempel wäre die
|
||||
// Reihenfolge sonst unbestimmt und ein Eintrag könnte auf zwei Seiten
|
||||
// erscheinen oder auf keiner.
|
||||
.orderBy("occurred_at", "desc")
|
||||
.orderBy("id", "desc")
|
||||
.limit(PAGE_SIZE)
|
||||
.offset((page - 1) * PAGE_SIZE)
|
||||
.execute(),
|
||||
base()
|
||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
return { entries, count: Number(total?.anzahl ?? 0) };
|
||||
});
|
||||
|
||||
const { data: entries, count } = await query;
|
||||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
||||
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Suspense>
|
||||
<AuditFilters />
|
||||
</Suspense>
|
||||
<p className="text-sm text-ink-muted">{count ?? 0} Einträge</p>
|
||||
<p className="text-sm text-ink-muted">{count} Einträge</p>
|
||||
|
||||
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||
<table className="w-full min-w-[800px] text-sm">
|
||||
@@ -74,7 +91,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(entries ?? []).map((entry) => {
|
||||
{entries.map((entry) => {
|
||||
return (
|
||||
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
||||
<td className="whitespace-nowrap px-4 py-2.5 tabular-nums text-ink-body">
|
||||
@@ -102,7 +119,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{(entries ?? []).length === 0 && (
|
||||
{entries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
|
||||
Keine Einträge gefunden.
|
||||
|
||||
@@ -1,68 +1,78 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
|
||||
import { loadPlacements, type ReportingLine } from "@/lib/placement";
|
||||
import { loadPlacements, loadReportingLines } from "@/lib/placement";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
type PageProps = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
const supabase = await createClient();
|
||||
const today = todayIso();
|
||||
|
||||
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie kommen
|
||||
// aus om_reporting_lines(). Beide Abfragen filtern *in* der Funktion, es
|
||||
// wandern also neun Zeilen über die Leitung und nicht achthundert.
|
||||
const [
|
||||
{ data: employee },
|
||||
{ data: ownLine },
|
||||
{ data: reportLines },
|
||||
{ data: history },
|
||||
{ data: dependents },
|
||||
{ data: notes },
|
||||
orgMaps,
|
||||
placements,
|
||||
openPositions,
|
||||
] = await Promise.all([
|
||||
supabase.from("employees").select("*").eq("id", id).single(),
|
||||
supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("employee_id", id).maybeSingle(),
|
||||
supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("acting_manager_id", id),
|
||||
supabase
|
||||
.from("employee_history")
|
||||
.select("*")
|
||||
.eq("employee_id", id)
|
||||
.order("event_date", { ascending: false })
|
||||
.order("created_at", { ascending: false }),
|
||||
supabase.from("employee_dependents").select("*").eq("employee_id", id).order("created_at"),
|
||||
supabase.from("employee_notes").select("*").eq("employee_id", id).order("created_at", { ascending: false }),
|
||||
loadOrgMaps(supabase),
|
||||
loadPlacements(supabase, { asOf: today, employeeIds: [id] }),
|
||||
loadOpenPositions(supabase),
|
||||
]);
|
||||
const data = await withUser(await currentUserId(), async (tx) => {
|
||||
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
|
||||
// kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der
|
||||
// Funktion ein, es wandern also neun Zeilen über die Leitung und nicht
|
||||
// achthundert.
|
||||
const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] =
|
||||
await Promise.all([
|
||||
tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(),
|
||||
loadReportingLines(tx, today, { employeeId: id }),
|
||||
loadReportingLines(tx, today, { actingManagerId: id }),
|
||||
tx
|
||||
.selectFrom("employee_history")
|
||||
.selectAll()
|
||||
.where("employee_id", "=", id)
|
||||
.orderBy("event_date", "desc")
|
||||
.orderBy("created_at", "desc")
|
||||
.execute(),
|
||||
tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(),
|
||||
tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(),
|
||||
loadOrgMaps(tx),
|
||||
loadPlacements(tx, { asOf: today, employeeIds: [id] }),
|
||||
loadOpenPositions(tx),
|
||||
]);
|
||||
|
||||
if (!employee) notFound();
|
||||
if (!employee) return null;
|
||||
const line = ownLines[0] ?? null;
|
||||
|
||||
const line = ownLine as ReportingLine | null;
|
||||
const reports = (reportLines ?? []) as ReportingLine[];
|
||||
|
||||
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
||||
// formal zuständige Leitung und die direkten Berichte.
|
||||
const relatedIds = Array.from(
|
||||
new Set(
|
||||
[line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter(
|
||||
(x): x is string => Boolean(x)
|
||||
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
||||
// formal zuständige Leitung und die direkten Berichte.
|
||||
const relatedIds = Array.from(
|
||||
new Set(
|
||||
[line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter(
|
||||
(x): x is string => Boolean(x)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
const { data: relatedRows } = relatedIds.length
|
||||
? await supabase.from("employees").select("id, first_name, last_name, job_title, status").in("id", relatedIds)
|
||||
: { data: [] };
|
||||
const byId = new Map((relatedRows ?? []).map((e) => [e.id, e]));
|
||||
);
|
||||
const relatedRows = relatedIds.length
|
||||
? await tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "job_title", "status"])
|
||||
.where("id", "in", relatedIds)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
const placement = placements.get(id) ?? null;
|
||||
return {
|
||||
employee,
|
||||
line,
|
||||
reports,
|
||||
history,
|
||||
dependents,
|
||||
notes,
|
||||
orgMaps,
|
||||
placement: placements.get(id) ?? null,
|
||||
openPositions,
|
||||
byId: new Map(relatedRows.map((e) => [e.id, e])),
|
||||
};
|
||||
});
|
||||
|
||||
if (!data) notFound();
|
||||
const { employee, line, reports, history, dependents, notes, orgMaps, placement, openPositions, byId } = data;
|
||||
|
||||
return (
|
||||
<EmployeeDetail
|
||||
|
||||
@@ -5,30 +5,16 @@ 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 { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||||
import { fmtDate, todayIso } from "@/lib/format";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
|
||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const COLUMNS =
|
||||
"id, first_name, last_name, personnel_number, job_title, location_id, entry_date, employment_type, weekly_hours, status, absence_type";
|
||||
|
||||
// Was applyFilters vom Query-Builder braucht — mehr nicht.
|
||||
type Narrowable = {
|
||||
eq: (column: string, value: string | number) => Narrowable;
|
||||
or: (filters: string) => Narrowable;
|
||||
gt: (column: string, value: string) => Narrowable;
|
||||
lte: (column: string, value: string) => Narrowable;
|
||||
gte: (column: string, value: string) => Narrowable;
|
||||
is: (column: string, value: null) => Narrowable;
|
||||
not: (column: string, operator: string, value: null) => Narrowable;
|
||||
};
|
||||
|
||||
type SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string };
|
||||
|
||||
type EmployeesPageProps = {
|
||||
@@ -47,25 +33,9 @@ function pageHref(params: SearchParams, page: number): string {
|
||||
|
||||
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;
|
||||
const today = todayIso();
|
||||
|
||||
// Die Referenzdaten kommen zuerst, weil der Bereichsfilter den Teilbaum
|
||||
// braucht: „Produktion" meint die Abteilungen und Teams darunter, nicht die
|
||||
// Einheit selbst — dort sitzt nur die Bereichsleitung.
|
||||
const orgMaps = await loadOrgMaps(supabase);
|
||||
|
||||
// Nach Organisationseinheit gefiltert wird über die laufende Besetzung.
|
||||
// `!inner` macht aus der Einbettung einen echten Join, sodass die Bedingung
|
||||
// die Person aus dem Ergebnis nimmt statt bloss ihre eingebettete Liste zu
|
||||
// leeren. Die Einbettung ändert die Form der Zeile, deshalb steht sie im
|
||||
// Select und nicht in einem nachträglichen Filter.
|
||||
const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null;
|
||||
|
||||
// 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 ?? "")
|
||||
@@ -73,47 +43,100 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
||||
.map((s) => s.trim())
|
||||
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
||||
|
||||
// Strukturell typisiert und generisch über den Builder, damit die beiden
|
||||
// Select-Formen unten ihre Zeilenform behalten. Ein bedingt
|
||||
// zusammengesetzter Select-String wird zu einer Union zweier Literale, die
|
||||
// der Typparser von postgrest-js nicht mehr auflösen kann — daher zwei
|
||||
// getrennte Abfragen mit einer gemeinsamen Filterkette.
|
||||
function applyFilters<Q extends Narrowable>(query: Q): Q {
|
||||
let q = query;
|
||||
if (params.q) {
|
||||
const term = params.q.trim();
|
||||
if (/^\d+$/.test(term)) q = q.eq("personnel_number", Number(term)) as Q;
|
||||
else {
|
||||
const safe = sanitizeIlikeTerm(term);
|
||||
q = q.or(`first_name.ilike.%${safe}%,last_name.ilike.%${safe}%,job_title.ilike.%${safe}%`) as Q;
|
||||
const { orgMaps, employees, count, placements } = 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.
|
||||
const base = () => {
|
||||
let q = tx.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)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
// Derived from the dates, not read off employees.status — see
|
||||
// lib/employee-status-filter.ts for why the two can disagree.
|
||||
q = applyDerivedStatusFilter(q, statuses, today);
|
||||
if (params.location) q = q.eq("location_id", params.location) as Q;
|
||||
return q;
|
||||
}
|
||||
|
||||
const { data: employeesData, count } = unitFilter
|
||||
? await applyFilters(
|
||||
supabase
|
||||
.from("employees")
|
||||
.select(`${COLUMNS}, position_assignments!inner(valid_to, om_positions!inner(org_unit_id))`, { count: "exact" })
|
||||
.order("last_name", { ascending: true })
|
||||
.range(from, to)
|
||||
.is("position_assignments.valid_to", null)
|
||||
.in("position_assignments.om_positions.org_unit_id", subtreeOf(orgMaps, unitFilter))
|
||||
)
|
||||
: await applyFilters(
|
||||
supabase.from("employees").select(COLUMNS, { count: "exact" }).order("last_name", { ascending: true }).range(from, to)
|
||||
);
|
||||
const employees = employeesData ?? [];
|
||||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
||||
if (params.q) {
|
||||
const term = params.q.trim();
|
||||
if (/^d+$/.test(term)) {
|
||||
q = q.where("personnel_number", "=", Number(term));
|
||||
} else {
|
||||
// Als Parameter gebunden statt in die Abfrage geschrieben: die
|
||||
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, sind
|
||||
// hier bedeutungslos.
|
||||
const like = `%${term}%`;
|
||||
q = q.where((eb) =>
|
||||
eb.or([eb("first_name", "ilike", like), eb("last_name", "ilike", like), eb("job_title", "ilike", like)])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
|
||||
// Seite, nicht für den ganzen Bestand.
|
||||
const placements = await loadPlacements(supabase, { asOf: today, employeeIds: employees.map((e) => e.id) });
|
||||
// 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;
|
||||
};
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
base()
|
||||
.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)
|
||||
.execute(),
|
||||
base()
|
||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||
.executeTakeFirst(),
|
||||
]);
|
||||
|
||||
// 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 };
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(count / PAGE_SIZE));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -2,36 +2,51 @@ import { redirect } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
||||
import { AppShell } from "@/components/shell/AppShell";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import { loadOpenNotes } from "@/lib/notes";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
|
||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) redirect("/login");
|
||||
const userId = await currentUserId();
|
||||
if (!userId) redirect("/login");
|
||||
|
||||
// Defense in depth: proxy.ts already redirects any non-active-HR session
|
||||
// away before this layout ever renders. Re-checking here means a gap in
|
||||
// the proxy matcher (or a future route added outside it) still fails
|
||||
// closed instead of silently granting access — see docs/security.md.
|
||||
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle();
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login");
|
||||
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
|
||||
// und damit nebenbei auf einem einheitlichen Lesestand.
|
||||
const data = await withUser(userId, async (tx) => {
|
||||
// Defense in depth: proxy.ts already redirects any non-active-HR session
|
||||
// away before this layout ever renders. Re-checking here means a gap in
|
||||
// the proxy matcher (or a future route added outside it) still fails
|
||||
// closed instead of silently granting access — see docs/security.md.
|
||||
const profile = await tx
|
||||
.selectFrom("profiles")
|
||||
.select(["full_name", "email", "role", "is_active"])
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (profile?.role !== "hr" || profile?.is_active !== true) return null;
|
||||
|
||||
const userLabel = profile.full_name || profile.email || user.email || "";
|
||||
const [openPositions, locations, drafts, openNotes] = await Promise.all([
|
||||
loadOpenPositions(tx),
|
||||
tx.selectFrom("locations").select(["id", "name", "country"]).orderBy("name").execute(),
|
||||
tx
|
||||
.selectFrom("hire_drafts")
|
||||
.select(["id", "step", "payload", "updated_at"])
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("updated_at", "desc")
|
||||
.execute(),
|
||||
loadOpenNotes(tx),
|
||||
]);
|
||||
|
||||
const [openPositions, locationsRes, draftsRes, openNotes] = await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
supabase.from("locations").select("id, name, country").order("name"),
|
||||
supabase.from("hire_drafts").select("id, step, payload, updated_at").eq("created_by", user.id).order("updated_at", { ascending: false }),
|
||||
loadOpenNotes(supabase),
|
||||
]);
|
||||
return { profile, openPositions, locations, drafts, openNotes };
|
||||
});
|
||||
|
||||
if (!data) redirect("/login");
|
||||
|
||||
const userLabel = data.profile.full_name || data.profile.email || "";
|
||||
|
||||
return (
|
||||
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
|
||||
<AppShell userLabel={userLabel} openNotes={openNotes}>
|
||||
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
|
||||
<AppShell userLabel={userLabel} openNotes={data.openNotes}>
|
||||
{children}
|
||||
</AppShell>
|
||||
</HireWizardProvider>
|
||||
|
||||
@@ -4,7 +4,8 @@ import type { OrgUnitNode } from "@/components/orgchart/types";
|
||||
import { todayIso } from "@/lib/format";
|
||||
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
||||
import { parseIsoDateParam } from "@/lib/reports";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
type SearchParams = { asOf?: string; focus?: string };
|
||||
|
||||
@@ -18,18 +19,19 @@ export default async function OrgChartPage({ searchParams }: { searchParams: Pro
|
||||
// so a junk value can't reach the client as an arbitrary string.
|
||||
const focusId = params.focus && UUID.test(params.focus) ? params.focus : null;
|
||||
|
||||
const supabase = await createClient();
|
||||
|
||||
const [org, { data: units }] = await Promise.all([
|
||||
loadOrgAsOf(supabase, asOf),
|
||||
supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"),
|
||||
]);
|
||||
const { org, units } = await withUser(await currentUserId(), async (tx) => {
|
||||
const [org, units] = await Promise.all([
|
||||
loadOrgAsOf(tx, asOf),
|
||||
tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(),
|
||||
]);
|
||||
return { org, units };
|
||||
});
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<OrgChartClient
|
||||
employees={org.employees}
|
||||
units={(units ?? []) as OrgUnitNode[]}
|
||||
units={units as OrgUnitNode[]}
|
||||
vacancies={org.vacancies}
|
||||
asOf={asOf}
|
||||
today={today}
|
||||
|
||||
@@ -8,8 +8,9 @@ import { divisionOf, loadOrgMaps } from "@/lib/org";
|
||||
import { loadPlacements } from "@/lib/placement";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { deriveStatusAsOf } from "@/lib/reports";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { fetchAllRows } from "@/lib/supabase/query";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
import type { HistoryEventType } from "@/lib/supabase/types";
|
||||
|
||||
// Each KPI carries a colour already; the accent bar repeats it in a second
|
||||
// channel so the tiles are scannable as a row rather than six identical
|
||||
@@ -39,18 +40,6 @@ const DOT_STYLES: Record<string, string> = {
|
||||
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const;
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const supabase = await createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
const { data: drafts } = user
|
||||
? await supabase
|
||||
.from("hire_drafts")
|
||||
.select("id, step, payload, updated_at")
|
||||
.eq("created_by", user.id)
|
||||
.order("updated_at", { ascending: false })
|
||||
: { data: [] };
|
||||
|
||||
// Built as strings, not by round-tripping a local Date through
|
||||
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
|
||||
// the previous year in UTC, which shifted the whole YTD window a day early
|
||||
@@ -61,6 +50,8 @@ export default async function DashboardPage() {
|
||||
const yearEnd = `${year}-12-31`;
|
||||
const in60Iso = addDaysIso(today, 60);
|
||||
|
||||
const userId = await currentUserId();
|
||||
|
||||
// Headcount, FTE, Karenz and the division bars all come from one full read
|
||||
// and the *derived* status, not from the `employees.status` column.
|
||||
//
|
||||
@@ -69,70 +60,116 @@ export default async function DashboardPage() {
|
||||
// planned hire whose start date has passed, or a Karenz that ended without
|
||||
// anyone recording the return, made the dashboard and the Berichte page
|
||||
// disagree about the same headcount. Same derivation, same numbers.
|
||||
// It also replaces four separate count queries with one.
|
||||
const [
|
||||
const {
|
||||
drafts,
|
||||
staffRows,
|
||||
hiresYtdRes,
|
||||
exitsYtdRes,
|
||||
hiresYtd,
|
||||
exitsYtd,
|
||||
openPositions,
|
||||
orgMaps,
|
||||
placements,
|
||||
upcomingHiresRes,
|
||||
upcomingExitsRes,
|
||||
upcomingReturnsRes,
|
||||
historyRes,
|
||||
] = await Promise.all([
|
||||
fetchAllRows(() =>
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, weekly_hours, entry_date, exit_date, karenz_start_date, karenz_return_date")
|
||||
.order("id")
|
||||
),
|
||||
// Entries/exits count history events, which is what the linked report
|
||||
// counts too. `entry_date` would also sweep up rehires, whose event is
|
||||
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
||||
// different numbers for the same year.
|
||||
supabase
|
||||
.from("employee_history")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.in("event_type", ["Eintritt", "Wiedereintritt"])
|
||||
.gte("event_date", yearStart)
|
||||
.lte("event_date", yearEnd),
|
||||
supabase
|
||||
.from("employee_history")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("event_type", "Austritt")
|
||||
.gte("event_date", yearStart)
|
||||
.lte("event_date", yearEnd),
|
||||
loadOpenPositions(supabase),
|
||||
loadOrgMaps(supabase),
|
||||
loadPlacements(supabase, { asOf: today }),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, entry_date")
|
||||
.eq("status", "Geplant")
|
||||
.gte("entry_date", today)
|
||||
.lte("entry_date", in60Iso),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, exit_date")
|
||||
.not("exit_date", "is", null)
|
||||
.gte("exit_date", today)
|
||||
.lte("exit_date", in60Iso),
|
||||
supabase
|
||||
.from("employees")
|
||||
.select("id, first_name, last_name, karenz_return_date")
|
||||
.eq("status", "Karenz")
|
||||
.not("karenz_return_date", "is", null)
|
||||
.gte("karenz_return_date", today)
|
||||
.lte("karenz_return_date", in60Iso),
|
||||
supabase
|
||||
.from("employee_history")
|
||||
.select("id, employee_id, event_date, event_type, description")
|
||||
.order("event_date", { ascending: false })
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(10),
|
||||
]);
|
||||
upcomingHires,
|
||||
upcomingExits,
|
||||
upcomingReturns,
|
||||
history,
|
||||
} = await withUser(userId, async (tx) => {
|
||||
const countIn = (types: readonly HistoryEventType[]) =>
|
||||
tx
|
||||
.selectFrom("employee_history")
|
||||
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||
.where("event_type", "in", [...types])
|
||||
.where("event_date", ">=", yearStart)
|
||||
.where("event_date", "<=", yearEnd)
|
||||
.executeTakeFirst();
|
||||
|
||||
const [
|
||||
drafts,
|
||||
staffRows,
|
||||
hiresYtd,
|
||||
exitsYtd,
|
||||
openPositions,
|
||||
orgMaps,
|
||||
placements,
|
||||
upcomingHires,
|
||||
upcomingExits,
|
||||
upcomingReturns,
|
||||
history,
|
||||
] = await Promise.all([
|
||||
userId
|
||||
? tx
|
||||
.selectFrom("hire_drafts")
|
||||
.select(["id", "step", "payload", "updated_at"])
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("updated_at", "desc")
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
|
||||
.orderBy("id")
|
||||
.execute(),
|
||||
|
||||
// Entries/exits count history events, which is what the linked report
|
||||
// counts too. `entry_date` would also sweep up rehires, whose event is
|
||||
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
||||
// different numbers for the same year.
|
||||
countIn(["Eintritt", "Wiedereintritt"]),
|
||||
countIn(["Austritt"]),
|
||||
|
||||
loadOpenPositions(tx),
|
||||
loadOrgMaps(tx),
|
||||
loadPlacements(tx, { asOf: today }),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "entry_date"])
|
||||
.where("status", "=", "Geplant")
|
||||
.where("entry_date", ">=", today)
|
||||
.where("entry_date", "<=", in60Iso)
|
||||
.execute(),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "exit_date"])
|
||||
.where("exit_date", "is not", null)
|
||||
.where("exit_date", ">=", today)
|
||||
.where("exit_date", "<=", in60Iso)
|
||||
.execute(),
|
||||
|
||||
tx
|
||||
.selectFrom("employees")
|
||||
.select(["id", "first_name", "last_name", "karenz_return_date"])
|
||||
.where("status", "=", "Karenz")
|
||||
.where("karenz_return_date", "is not", null)
|
||||
.where("karenz_return_date", ">=", today)
|
||||
.where("karenz_return_date", "<=", in60Iso)
|
||||
.execute(),
|
||||
|
||||
tx
|
||||
.selectFrom("employee_history as h")
|
||||
.leftJoin("employees as e", "e.id", "h.employee_id")
|
||||
.select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"])
|
||||
.orderBy("h.event_date", "desc")
|
||||
.orderBy("h.created_at", "desc")
|
||||
.limit(10)
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
return {
|
||||
drafts,
|
||||
staffRows,
|
||||
hiresYtd: Number(hiresYtd?.anzahl ?? 0),
|
||||
exitsYtd: Number(exitsYtd?.anzahl ?? 0),
|
||||
openPositions,
|
||||
orgMaps,
|
||||
placements,
|
||||
upcomingHires,
|
||||
upcomingExits,
|
||||
upcomingReturns,
|
||||
history,
|
||||
};
|
||||
});
|
||||
|
||||
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
||||
// active, and is counted by its own tile instead. FTE follows the same
|
||||
@@ -166,19 +203,19 @@ export default async function DashboardPage() {
|
||||
|
||||
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
|
||||
const upcoming: UpcomingItem[] = [
|
||||
...(upcomingHiresRes.data ?? []).map((e) => ({
|
||||
...(upcomingHires).map((e) => ({
|
||||
id: e.id,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
date: e.entry_date,
|
||||
kind: "hire" as const,
|
||||
})),
|
||||
...(upcomingExitsRes.data ?? []).map((e) => ({
|
||||
...(upcomingExits).map((e) => ({
|
||||
id: e.id,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
date: e.exit_date!,
|
||||
kind: "exit" as const,
|
||||
})),
|
||||
...(upcomingReturnsRes.data ?? []).map((e) => ({
|
||||
...(upcomingReturns).map((e) => ({
|
||||
id: e.id,
|
||||
label: `${e.first_name} ${e.last_name}`,
|
||||
date: e.karenz_return_date!,
|
||||
@@ -188,12 +225,6 @@ export default async function DashboardPage() {
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
.slice(0, 8);
|
||||
|
||||
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
|
||||
const historyEmployeesRes = historyEmployeeIds.length
|
||||
? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds)
|
||||
: { data: [] as { id: string; first_name: string; last_name: string }[] };
|
||||
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
||||
|
||||
// Each tile links to the view that shows what it counts, with the filters
|
||||
// pre-applied.
|
||||
//
|
||||
@@ -214,13 +245,13 @@ export default async function DashboardPage() {
|
||||
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
||||
{
|
||||
label: "Eintritte (Jahr)",
|
||||
value: hiresYtdRes.count ?? 0,
|
||||
value: hiresYtd,
|
||||
tone: "success",
|
||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
||||
},
|
||||
{
|
||||
label: "Austritte (Jahr)",
|
||||
value: exitsYtdRes.count ?? 0,
|
||||
value: exitsYtd,
|
||||
tone: "danger",
|
||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||
},
|
||||
@@ -300,14 +331,14 @@ export default async function DashboardPage() {
|
||||
<Card>
|
||||
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
||||
{(historyRes.data ?? []).map((h) => (
|
||||
{(history).map((h) => (
|
||||
<li key={h.id} className="flex gap-2.5 py-2.5">
|
||||
{/* Dot aligned to the first line of text, not centred on the
|
||||
whole row, so it stays put as descriptions wrap. */}
|
||||
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="text-sm font-semibold text-ink">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span>
|
||||
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? `${h.first_name} ${h.last_name}` : "Unbekannt"}</span>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||
{h.event_type}
|
||||
</span>
|
||||
@@ -316,7 +347,7 @@ export default async function DashboardPage() {
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
||||
{(history).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -3,21 +3,23 @@ import { PositionsPageClient } from "@/components/positions/PositionsPageClient"
|
||||
import { daysBetweenIso } from "@/lib/format";
|
||||
import { loadOrgMaps } from "@/lib/org";
|
||||
import { loadOpenPositions } from "@/lib/positions";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
export default async function PositionsPage() {
|
||||
const supabase = await createClient();
|
||||
const { openPositions, orgMaps, chiefRows } = await withUser(await currentUserId(), async (tx) => {
|
||||
const [openPositions, orgMaps, chiefRows] = await Promise.all([
|
||||
loadOpenPositions(tx),
|
||||
loadOrgMaps(tx),
|
||||
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
|
||||
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
|
||||
// Fehlermeldung nach dem Absenden.
|
||||
tx.selectFrom("om_positions").select("org_unit_id").where("is_chief", "=", true).where("valid_to", "is", null).execute(),
|
||||
]);
|
||||
return { openPositions, orgMaps, chiefRows };
|
||||
});
|
||||
|
||||
const [openPositions, orgMaps, { data: chiefRows }] = await Promise.all([
|
||||
loadOpenPositions(supabase),
|
||||
loadOrgMaps(supabase),
|
||||
// Wo es schon eine gültige Leitungsplanstelle gibt, lässt der
|
||||
// Unique-Index keine zweite zu — das gehört in den Dialog, nicht in eine
|
||||
// Fehlermeldung nach dem Absenden.
|
||||
supabase.from("om_positions").select("org_unit_id").eq("is_chief", true).is("valid_to", null),
|
||||
]);
|
||||
|
||||
const withChief = new Set((chiefRows ?? []).map((r) => r.org_unit_id));
|
||||
const withChief = new Set(chiefRows.map((r) => r.org_unit_id));
|
||||
const units: UnitOption[] = orgMaps.unitList.map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
totalForRows,
|
||||
} from "@/lib/reports";
|
||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { currentUserId } from "@/lib/auth/session";
|
||||
import { withUser } from "@/lib/db";
|
||||
|
||||
type SearchParams = {
|
||||
mode?: string;
|
||||
@@ -35,7 +36,6 @@ type SearchParams = {
|
||||
|
||||
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
const params = await searchParams;
|
||||
const supabase = await createClient();
|
||||
const mode = parseMode(params.mode);
|
||||
|
||||
// Both modes are parsed up front so the data load can start before
|
||||
@@ -55,34 +55,44 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
// in, so all three go out together. Against a hosted database a round trip
|
||||
// costs about as much as the query itself, which made this page's three
|
||||
// sequential waves its dominant cost.
|
||||
const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([
|
||||
loadOrgLookups(supabase),
|
||||
supabase.auth.getUser(),
|
||||
mode === "events"
|
||||
? loadEventHistory(supabase, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
from,
|
||||
to,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
mode === "snapshot"
|
||||
? loadSnapshotEmployees(supabase, {
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
status: params.status,
|
||||
employment: params.employment,
|
||||
asOf,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const userId = await currentUserId();
|
||||
|
||||
const user = userRes.user;
|
||||
// Still a wave of its own: it needs the user id the call above resolves.
|
||||
const { data: savedReports } = user
|
||||
? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false })
|
||||
: { data: [] };
|
||||
// Alles in einer Transaktion — dort gilt der Sitzungskontext, und der
|
||||
// Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei
|
||||
// Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste
|
||||
// Teil dieser Seite war.
|
||||
const { lookups, divisions, locations, events, employees, savedReports } = await withUser(userId, async (tx) => {
|
||||
const [{ lookups, divisions, locations }, events, employees, savedReports] = await Promise.all([
|
||||
loadOrgLookups(tx),
|
||||
mode === "events"
|
||||
? loadEventHistory(tx, {
|
||||
eventType: eventType ?? undefined,
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
from,
|
||||
to,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
mode === "snapshot"
|
||||
? loadSnapshotEmployees(tx, {
|
||||
division: params.division,
|
||||
location: params.location,
|
||||
status: params.status,
|
||||
employment: params.employment,
|
||||
asOf,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
userId
|
||||
? tx
|
||||
.selectFrom("saved_reports")
|
||||
.select(["id", "name", "config"])
|
||||
.where("created_by", "=", userId)
|
||||
.orderBy("created_at", "desc")
|
||||
.execute()
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
return { lookups, divisions, locations, events, employees, savedReports };
|
||||
});
|
||||
|
||||
if (mode === "events") {
|
||||
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
|
||||
@@ -100,7 +110,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
recordCount={events.length}
|
||||
divisions={divisions}
|
||||
locations={locations}
|
||||
savedReports={savedReports ?? []}
|
||||
savedReports={savedReports}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -127,7 +137,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
||||
recordCount={employees.length}
|
||||
divisions={divisions}
|
||||
locations={locations}
|
||||
savedReports={savedReports ?? []}
|
||||
savedReports={savedReports}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user