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:
@@ -1,18 +1,16 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import type { CollectiveAgreement, Database, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
import { withUser } from "@/lib/db";
|
||||||
|
import { callFunction, runMutation, type ActionResult, type MutationFn } from "@/lib/db/rpc";
|
||||||
type ActionResult = { success: boolean; error?: string };
|
import type { CollectiveAgreement, NoteCategory, RelationshipType, Weekday, WorkerType } from "@/lib/supabase/types";
|
||||||
type MutationFn = keyof Database["public"]["Functions"];
|
|
||||||
|
|
||||||
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
async function callRpc(fn: MutationFn, payload: Record<string, unknown>, revalidate: string[]): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
const result = await runMutation(await currentUserId(), fn, payload);
|
||||||
const { error } = await supabase.rpc(fn, { payload });
|
if (!result.success) return result;
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
for (const path of revalidate) revalidatePath(path);
|
for (const path of revalidate) revalidatePath(path);
|
||||||
return { success: true };
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hireEmployee(payload: {
|
export async function hireEmployee(payload: {
|
||||||
@@ -43,13 +41,19 @@ export async function hireEmployee(payload: {
|
|||||||
is_laterale_fuehrung?: boolean;
|
is_laterale_fuehrung?: boolean;
|
||||||
is_c_level?: boolean;
|
is_c_level?: boolean;
|
||||||
}): Promise<ActionResult & { employeeId?: string }> {
|
}): Promise<ActionResult & { employeeId?: string }> {
|
||||||
const supabase = await createClient();
|
// Einzige Mutation, deren Rückgabewert gebraucht wird: die neue
|
||||||
const { data, error } = await supabase.rpc("hire_employee", { payload });
|
// Personen-Kennung, damit die Oberfläche direkt auf die Akte springen kann.
|
||||||
if (error) return { success: false, error: error.message };
|
try {
|
||||||
revalidatePath("/employees");
|
const employeeId = await withUser(await currentUserId(), (tx) =>
|
||||||
revalidatePath("/");
|
callFunction(tx, "hire_employee", payload as Record<string, unknown>)
|
||||||
revalidatePath("/positions");
|
);
|
||||||
return { success: true, employeeId: data as string };
|
revalidatePath("/employees");
|
||||||
|
revalidatePath("/");
|
||||||
|
revalidatePath("/positions");
|
||||||
|
return { success: true, employeeId: employeeId as string };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function terminateEmployee(payload: {
|
export async function terminateEmployee(payload: {
|
||||||
|
|||||||
@@ -1,45 +1,52 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
type ActionResult = { success: boolean; error?: string };
|
import type { ActionResult } from "@/lib/db/rpc";
|
||||||
|
|
||||||
export async function saveHireDraft(payload: {
|
export async function saveHireDraft(payload: {
|
||||||
id?: string;
|
id?: string;
|
||||||
step: number;
|
step: number;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
}): Promise<ActionResult & { id?: string }> {
|
}): Promise<ActionResult & { id?: string }> {
|
||||||
const supabase = await createClient();
|
const userId = await currentUserId();
|
||||||
const {
|
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
try {
|
||||||
if (!user) return { success: false, error: "Nicht angemeldet." };
|
const id = await withUser(userId, async (tx) => {
|
||||||
|
if (payload.id) {
|
||||||
|
// Ob die Zeile der aufrufenden Person gehört, entscheidet die
|
||||||
|
// Policy hire_drafts_owner — nicht eine Prüfung hier.
|
||||||
|
await tx
|
||||||
|
.updateTable("hire_drafts")
|
||||||
|
.set({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
||||||
|
.where("id", "=", payload.id)
|
||||||
|
.execute();
|
||||||
|
return payload.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await tx
|
||||||
|
.insertInto("hire_drafts")
|
||||||
|
.values({ created_by: userId, step: payload.step, payload: payload.data })
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return row.id;
|
||||||
|
});
|
||||||
|
|
||||||
if (payload.id) {
|
|
||||||
const { error } = await supabase
|
|
||||||
.from("hire_drafts")
|
|
||||||
.update({ step: payload.step, payload: payload.data, updated_at: new Date().toISOString() })
|
|
||||||
.eq("id", payload.id);
|
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
return { success: true, id: payload.id };
|
return { success: true, id };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error } = await supabase
|
|
||||||
.from("hire_drafts")
|
|
||||||
.insert({ created_by: user.id, step: payload.step, payload: payload.data })
|
|
||||||
.select("id")
|
|
||||||
.single();
|
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
revalidatePath("/");
|
|
||||||
return { success: true, id: data.id };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteHireDraft(id: string): Promise<ActionResult> {
|
export async function deleteHireDraft(id: string): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
try {
|
||||||
const { error } = await supabase.from("hire_drafts").delete().eq("id", id);
|
await withUser(await currentUserId(), (tx) => tx.deleteFrom("hire_drafts").where("id", "=", id).execute());
|
||||||
if (error) return { success: false, error: error.message };
|
revalidatePath("/");
|
||||||
revalidatePath("/");
|
return { success: true };
|
||||||
return { success: true };
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { runMutation, type ActionResult } from "@/lib/db/rpc";
|
||||||
type ActionResult = { success: boolean; error?: string };
|
|
||||||
|
|
||||||
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
const POSITION_PATHS = ["/positions", "/orgchart", "/"];
|
||||||
|
|
||||||
@@ -12,11 +11,10 @@ async function callRpc(
|
|||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
revalidate: string[]
|
revalidate: string[]
|
||||||
): Promise<ActionResult> {
|
): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
const result = await runMutation(await currentUserId(), fn, payload);
|
||||||
const { error } = await supabase.rpc(fn, { payload });
|
if (!result.success) return result;
|
||||||
if (error) return { success: false, error: error.message };
|
|
||||||
for (const path of revalidate) revalidatePath(path);
|
for (const path of revalidate) revalidatePath(path);
|
||||||
return { success: true };
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPosition(payload: {
|
export async function createPosition(payload: {
|
||||||
|
|||||||
@@ -1,27 +1,31 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
type ActionResult = { success: boolean; error?: string };
|
import type { ActionResult } from "@/lib/db/rpc";
|
||||||
|
|
||||||
export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> {
|
export async function saveReport(payload: { name: string; config: Record<string, unknown> }): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
const userId = await currentUserId();
|
||||||
const {
|
if (!userId) return { success: false, error: "Nicht angemeldet." };
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
if (!user) return { success: false, error: "Nicht angemeldet." };
|
|
||||||
|
|
||||||
const { error } = await supabase.from("saved_reports").insert({ created_by: user.id, name: payload.name, config: payload.config });
|
try {
|
||||||
if (error) return { success: false, error: error.message };
|
await withUser(userId, (tx) =>
|
||||||
revalidatePath("/reports");
|
tx.insertInto("saved_reports").values({ created_by: userId, name: payload.name, config: payload.config }).execute()
|
||||||
return { success: true };
|
);
|
||||||
|
revalidatePath("/reports");
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteReport(id: string): Promise<ActionResult> {
|
export async function deleteReport(id: string): Promise<ActionResult> {
|
||||||
const supabase = await createClient();
|
try {
|
||||||
const { error } = await supabase.from("saved_reports").delete().eq("id", id);
|
await withUser(await currentUserId(), (tx) => tx.deleteFrom("saved_reports").where("id", "=", id).execute());
|
||||||
if (error) return { success: false, error: error.message };
|
revalidatePath("/reports");
|
||||||
revalidatePath("/reports");
|
return { success: true };
|
||||||
return { success: true };
|
} catch (err) {
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { AuditFilters } from "@/components/audit/AuditFilters";
|
|||||||
import { CARD_CLASS } from "@/components/ui/Card";
|
import { CARD_CLASS } from "@/components/ui/Card";
|
||||||
import { Pagination } from "@/components/ui/Pagination";
|
import { Pagination } from "@/components/ui/Pagination";
|
||||||
import { actionBadgeStyle } from "@/lib/colors";
|
import { actionBadgeStyle } from "@/lib/colors";
|
||||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -34,33 +34,50 @@ const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
|
|||||||
|
|
||||||
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function AuditPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
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
|
const { entries, count } = await withUser(await currentUserId(), async (tx) => {
|
||||||
.from("audit_log")
|
const base = () => {
|
||||||
.select("id, occurred_at, actor_name, action, target_label, target_employee_id, details", { count: "exact" })
|
let q = tx.selectFrom("audit_log");
|
||||||
.order("occurred_at", { ascending: false })
|
if (params.action) q = q.where("action", "=", params.action);
|
||||||
.range(from, to);
|
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);
|
const [entries, total] = await Promise.all([
|
||||||
if (params.q) {
|
base()
|
||||||
const q = sanitizeIlikeTerm(params.q.trim());
|
.select(["id", "occurred_at", "actor_name", "action", "target_label", "target_employee_id", "details"])
|
||||||
query = query.or(`target_label.ilike.%${q}%,details.ilike.%${q}%,actor_name.ilike.%${q}%`);
|
// 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 / PAGE_SIZE));
|
||||||
const totalPages = Math.max(1, Math.ceil((count ?? 0) / PAGE_SIZE));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<AuditFilters />
|
<AuditFilters />
|
||||||
</Suspense>
|
</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}`}>
|
<div className={`overflow-x-auto ${CARD_CLASS}`}>
|
||||||
<table className="w-full min-w-[800px] text-sm">
|
<table className="w-full min-w-[800px] text-sm">
|
||||||
@@ -74,7 +91,7 @@ export default async function AuditPage({ searchParams }: { searchParams: Promis
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(entries ?? []).map((entry) => {
|
{entries.map((entry) => {
|
||||||
return (
|
return (
|
||||||
<tr key={entry.id} className="border-b border-border-subtle transition-colors last:border-0 hover:bg-brand-50">
|
<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">
|
<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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(entries ?? []).length === 0 && (
|
{entries.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
|
<td colSpan={5} className="px-4 py-8 text-center text-sm text-ink-muted">
|
||||||
Keine Einträge gefunden.
|
Keine Einträge gefunden.
|
||||||
|
|||||||
@@ -1,68 +1,78 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
import { EmployeeDetail } from "@/components/employees/EmployeeDetail";
|
||||||
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
import { todayIso } from "@/lib/format";
|
import { todayIso } from "@/lib/format";
|
||||||
import { breadcrumbLabel, loadOrgMaps } from "@/lib/org";
|
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 { loadOpenPositions } from "@/lib/positions";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
|
||||||
|
|
||||||
type PageProps = { params: Promise<{ id: string }> };
|
type PageProps = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
export default async function EmployeeDetailPage({ params }: PageProps) {
|
export default async function EmployeeDetailPage({ params }: PageProps) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const supabase = await createClient();
|
|
||||||
const today = todayIso();
|
const today = todayIso();
|
||||||
|
|
||||||
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie kommen
|
const data = await withUser(await currentUserId(), async (tx) => {
|
||||||
// aus om_reporting_lines(). Beide Abfragen filtern *in* der Funktion, es
|
// Vorgesetzte und direkte Berichte stehen nirgends als Spalte — sie
|
||||||
// wandern also neun Zeilen über die Leitung und nicht achthundert.
|
// kommen aus om_reporting_lines(). Beide Abfragen schränken *in* der
|
||||||
const [
|
// Funktion ein, es wandern also neun Zeilen über die Leitung und nicht
|
||||||
{ data: employee },
|
// achthundert.
|
||||||
{ data: ownLine },
|
const [employee, ownLines, reports, history, dependents, notes, orgMaps, placements, openPositions] =
|
||||||
{ data: reportLines },
|
await Promise.all([
|
||||||
{ data: history },
|
tx.selectFrom("employees").selectAll().where("id", "=", id).executeTakeFirst(),
|
||||||
{ data: dependents },
|
loadReportingLines(tx, today, { employeeId: id }),
|
||||||
{ data: notes },
|
loadReportingLines(tx, today, { actingManagerId: id }),
|
||||||
orgMaps,
|
tx
|
||||||
placements,
|
.selectFrom("employee_history")
|
||||||
openPositions,
|
.selectAll()
|
||||||
] = await Promise.all([
|
.where("employee_id", "=", id)
|
||||||
supabase.from("employees").select("*").eq("id", id).single(),
|
.orderBy("event_date", "desc")
|
||||||
supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("employee_id", id).maybeSingle(),
|
.orderBy("created_at", "desc")
|
||||||
supabase.rpc("om_reporting_lines", { p_as_of: today }).eq("acting_manager_id", id),
|
.execute(),
|
||||||
supabase
|
tx.selectFrom("employee_dependents").selectAll().where("employee_id", "=", id).orderBy("created_at").execute(),
|
||||||
.from("employee_history")
|
tx.selectFrom("employee_notes").selectAll().where("employee_id", "=", id).orderBy("created_at", "desc").execute(),
|
||||||
.select("*")
|
loadOrgMaps(tx),
|
||||||
.eq("employee_id", id)
|
loadPlacements(tx, { asOf: today, employeeIds: [id] }),
|
||||||
.order("event_date", { ascending: false })
|
loadOpenPositions(tx),
|
||||||
.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),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!employee) notFound();
|
if (!employee) return null;
|
||||||
|
const line = ownLines[0] ?? null;
|
||||||
|
|
||||||
const line = ownLine as ReportingLine | null;
|
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
||||||
const reports = (reportLines ?? []) as ReportingLine[];
|
// formal zuständige Leitung und die direkten Berichte.
|
||||||
|
const relatedIds = Array.from(
|
||||||
// Namen für die beteiligten Personen in einem Zug: die Vertretung, die
|
new Set(
|
||||||
// formal zuständige Leitung und die direkten Berichte.
|
[line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter(
|
||||||
const relatedIds = Array.from(
|
(x): x is string => Boolean(x)
|
||||||
new Set(
|
)
|
||||||
[line?.acting_manager_id, line?.formal_manager_id, ...reports.map((r) => r.employee_id)].filter(
|
|
||||||
(x): x is string => Boolean(x)
|
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
);
|
const relatedRows = relatedIds.length
|
||||||
const { data: relatedRows } = relatedIds.length
|
? await tx
|
||||||
? await supabase.from("employees").select("id, first_name, last_name, job_title, status").in("id", relatedIds)
|
.selectFrom("employees")
|
||||||
: { data: [] };
|
.select(["id", "first_name", "last_name", "job_title", "status"])
|
||||||
const byId = new Map((relatedRows ?? []).map((e) => [e.id, e]));
|
.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 (
|
return (
|
||||||
<EmployeeDetail
|
<EmployeeDetail
|
||||||
|
|||||||
@@ -5,30 +5,16 @@ import { Avatar } from "@/components/ui/Avatar";
|
|||||||
import { CARD_CLASS } from "@/components/ui/Card";
|
import { CARD_CLASS } from "@/components/ui/Card";
|
||||||
import { Pagination } from "@/components/ui/Pagination";
|
import { Pagination } from "@/components/ui/Pagination";
|
||||||
import { StatusChip } from "@/components/ui/StatusChip";
|
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 { fmtDate, todayIso } from "@/lib/format";
|
||||||
import { loadPlacements } from "@/lib/placement";
|
|
||||||
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
|
import { breadcrumbLabel, divisionOf, loadOrgMaps, subtreeOf, unitOf } from "@/lib/org";
|
||||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
import { loadPlacements } from "@/lib/placement";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
|
||||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 15;
|
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 SearchParams = { q?: string; division?: string; status?: string; location?: string; page?: string };
|
||||||
|
|
||||||
type EmployeesPageProps = {
|
type EmployeesPageProps = {
|
||||||
@@ -47,25 +33,9 @@ function pageHref(params: SearchParams, page: number): string {
|
|||||||
|
|
||||||
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
export default async function EmployeesPage({ searchParams }: EmployeesPageProps) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const supabase = await createClient();
|
|
||||||
|
|
||||||
const page = Math.max(1, Number(params.page ?? "1") || 1);
|
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();
|
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
|
// Comma-separated, so a dashboard tile can link here with the same
|
||||||
// status set it counted rather than a narrower one.
|
// status set it counted rather than a narrower one.
|
||||||
const statuses = (params.status ?? "")
|
const statuses = (params.status ?? "")
|
||||||
@@ -73,47 +43,100 @@ export default async function EmployeesPage({ searchParams }: EmployeesPageProps
|
|||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter((s): s is EmploymentStatus => (["Aktiv", "Karenz", "Geplant", "Ausgetreten"] as const).includes(s as EmploymentStatus));
|
.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
|
const { orgMaps, employees, count, placements } = await withUser(await currentUserId(), async (tx) => {
|
||||||
// Select-Formen unten ihre Zeilenform behalten. Ein bedingt
|
// Die Referenzdaten zuerst: der Bereichsfilter braucht den Teilbaum.
|
||||||
// zusammengesetzter Select-String wird zu einer Union zweier Literale, die
|
// „Produktion" meint die Abteilungen und Teams darunter — in der Einheit
|
||||||
// der Typparser von postgrest-js nicht mehr auflösen kann — daher zwei
|
// selbst sitzt nur die Bereichsleitung.
|
||||||
// getrennte Abfragen mit einer gemeinsamen Filterkette.
|
const orgMaps = await loadOrgMaps(tx);
|
||||||
function applyFilters<Q extends Narrowable>(query: Q): Q {
|
const unitFilter = params.division && orgMaps.units.has(params.division) ? params.division : null;
|
||||||
let q = query;
|
|
||||||
if (params.q) {
|
// Eine Filterkette, zwei Abfragen: eine für die Seite, eine für die
|
||||||
const term = params.q.trim();
|
// Gesamtzahl. Am direkten Zugang teilen sie sich denselben Aufbau —
|
||||||
if (/^\d+$/.test(term)) q = q.eq("personnel_number", Number(term)) as Q;
|
// vorher brauchte es zwei getrennte Select-Formen, weil der Typparser der
|
||||||
else {
|
// API-Schicht einen bedingt zusammengesetzten Select-String nicht
|
||||||
const safe = sanitizeIlikeTerm(term);
|
// auflösen konnte.
|
||||||
q = q.or(`first_name.ilike.%${safe}%,last_name.ilike.%${safe}%,job_title.ilike.%${safe}%`) as Q;
|
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
|
if (params.q) {
|
||||||
? await applyFilters(
|
const term = params.q.trim();
|
||||||
supabase
|
if (/^d+$/.test(term)) {
|
||||||
.from("employees")
|
q = q.where("personnel_number", "=", Number(term));
|
||||||
.select(`${COLUMNS}, position_assignments!inner(valid_to, om_positions!inner(org_unit_id))`, { count: "exact" })
|
} else {
|
||||||
.order("last_name", { ascending: true })
|
// Als Parameter gebunden statt in die Abfrage geschrieben: die
|
||||||
.range(from, to)
|
// Zeichen, die in der alten Filtersyntax ausbrechen konnten, sind
|
||||||
.is("position_assignments.valid_to", null)
|
// hier bedeutungslos.
|
||||||
.in("position_assignments.om_positions.org_unit_id", subtreeOf(orgMaps, unitFilter))
|
const like = `%${term}%`;
|
||||||
)
|
q = q.where((eb) =>
|
||||||
: await applyFilters(
|
eb.or([eb("first_name", "ilike", like), eb("last_name", "ilike", like), eb("job_title", "ilike", like)])
|
||||||
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));
|
|
||||||
|
|
||||||
// Die Einordnung kommt über die Planstelle — nur für die 15 Zeilen dieser
|
// Derived from the dates, not read off employees.status — see
|
||||||
// Seite, nicht für den ganzen Bestand.
|
// lib/employee-status-filter.ts for why the two can disagree.
|
||||||
const placements = await loadPlacements(supabase, { asOf: today, employeeIds: employees.map((e) => e.id) });
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -2,36 +2,51 @@ import { redirect } from "next/navigation";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
import { HireWizardProvider } from "@/components/hire/HireWizardContext";
|
||||||
import { AppShell } from "@/components/shell/AppShell";
|
import { AppShell } from "@/components/shell/AppShell";
|
||||||
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
import { loadOpenNotes } from "@/lib/notes";
|
import { loadOpenNotes } from "@/lib/notes";
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
import { loadOpenPositions } from "@/lib/positions";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
|
||||||
|
|
||||||
export default async function AppLayout({ children }: { children: ReactNode }) {
|
export default async function AppLayout({ children }: { children: ReactNode }) {
|
||||||
const supabase = await createClient();
|
const userId = await currentUserId();
|
||||||
const {
|
if (!userId) redirect("/login");
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
if (!user) redirect("/login");
|
|
||||||
|
|
||||||
// Defense in depth: proxy.ts already redirects any non-active-HR session
|
// Alles in *einer* Transaktion, weil nur dort der Sitzungskontext gilt —
|
||||||
// away before this layout ever renders. Re-checking here means a gap in
|
// und damit nebenbei auf einem einheitlichen Lesestand.
|
||||||
// the proxy matcher (or a future route added outside it) still fails
|
const data = await withUser(userId, async (tx) => {
|
||||||
// closed instead of silently granting access — see docs/security.md.
|
// Defense in depth: proxy.ts already redirects any non-active-HR session
|
||||||
const { data: profile } = await supabase.from("profiles").select("full_name, email, role, is_active").eq("id", user.id).maybeSingle();
|
// away before this layout ever renders. Re-checking here means a gap in
|
||||||
if (profile?.role !== "hr" || profile?.is_active !== true) redirect("/login");
|
// 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([
|
return { profile, openPositions, locations, drafts, openNotes };
|
||||||
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 }),
|
if (!data) redirect("/login");
|
||||||
loadOpenNotes(supabase),
|
|
||||||
]);
|
const userLabel = data.profile.full_name || data.profile.email || "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HireWizardProvider openPositions={openPositions} locations={locationsRes.data ?? []} drafts={draftsRes.data ?? []}>
|
<HireWizardProvider openPositions={data.openPositions} locations={data.locations} drafts={data.drafts}>
|
||||||
<AppShell userLabel={userLabel} openNotes={openNotes}>
|
<AppShell userLabel={userLabel} openNotes={data.openNotes}>
|
||||||
{children}
|
{children}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</HireWizardProvider>
|
</HireWizardProvider>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { OrgUnitNode } from "@/components/orgchart/types";
|
|||||||
import { todayIso } from "@/lib/format";
|
import { todayIso } from "@/lib/format";
|
||||||
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
import { loadOrgAsOf } from "@/lib/orgchart-data";
|
||||||
import { parseIsoDateParam } from "@/lib/reports";
|
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 };
|
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.
|
// 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 focusId = params.focus && UUID.test(params.focus) ? params.focus : null;
|
||||||
|
|
||||||
const supabase = await createClient();
|
const { org, units } = await withUser(await currentUserId(), async (tx) => {
|
||||||
|
const [org, units] = await Promise.all([
|
||||||
const [org, { data: units }] = await Promise.all([
|
loadOrgAsOf(tx, asOf),
|
||||||
loadOrgAsOf(supabase, asOf),
|
tx.selectFrom("org_units").select(["id", "org_number", "name", "parent_id", "unit_type"]).orderBy("org_number").execute(),
|
||||||
supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"),
|
]);
|
||||||
]);
|
return { org, units };
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<OrgChartClient
|
<OrgChartClient
|
||||||
employees={org.employees}
|
employees={org.employees}
|
||||||
units={(units ?? []) as OrgUnitNode[]}
|
units={units as OrgUnitNode[]}
|
||||||
vacancies={org.vacancies}
|
vacancies={org.vacancies}
|
||||||
asOf={asOf}
|
asOf={asOf}
|
||||||
today={today}
|
today={today}
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import { divisionOf, loadOrgMaps } from "@/lib/org";
|
|||||||
import { loadPlacements } from "@/lib/placement";
|
import { loadPlacements } from "@/lib/placement";
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
import { loadOpenPositions } from "@/lib/positions";
|
||||||
import { deriveStatusAsOf } from "@/lib/reports";
|
import { deriveStatusAsOf } from "@/lib/reports";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { currentUserId } from "@/lib/auth/session";
|
||||||
import { fetchAllRows } from "@/lib/supabase/query";
|
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
|
// 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
|
// 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;
|
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const;
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
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
|
// Built as strings, not by round-tripping a local Date through
|
||||||
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
|
// 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
|
// 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 yearEnd = `${year}-12-31`;
|
||||||
const in60Iso = addDaysIso(today, 60);
|
const in60Iso = addDaysIso(today, 60);
|
||||||
|
|
||||||
|
const userId = await currentUserId();
|
||||||
|
|
||||||
// Headcount, FTE, Karenz and the division bars all come from one full read
|
// Headcount, FTE, Karenz and the division bars all come from one full read
|
||||||
// and the *derived* status, not from the `employees.status` column.
|
// 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
|
// planned hire whose start date has passed, or a Karenz that ended without
|
||||||
// anyone recording the return, made the dashboard and the Berichte page
|
// anyone recording the return, made the dashboard and the Berichte page
|
||||||
// disagree about the same headcount. Same derivation, same numbers.
|
// disagree about the same headcount. Same derivation, same numbers.
|
||||||
// It also replaces four separate count queries with one.
|
const {
|
||||||
const [
|
drafts,
|
||||||
staffRows,
|
staffRows,
|
||||||
hiresYtdRes,
|
hiresYtd,
|
||||||
exitsYtdRes,
|
exitsYtd,
|
||||||
openPositions,
|
openPositions,
|
||||||
orgMaps,
|
orgMaps,
|
||||||
placements,
|
placements,
|
||||||
upcomingHiresRes,
|
upcomingHires,
|
||||||
upcomingExitsRes,
|
upcomingExits,
|
||||||
upcomingReturnsRes,
|
upcomingReturns,
|
||||||
historyRes,
|
history,
|
||||||
] = await Promise.all([
|
} = await withUser(userId, async (tx) => {
|
||||||
fetchAllRows(() =>
|
const countIn = (types: readonly HistoryEventType[]) =>
|
||||||
supabase
|
tx
|
||||||
.from("employees")
|
.selectFrom("employee_history")
|
||||||
.select("id, weekly_hours, entry_date, exit_date, karenz_start_date, karenz_return_date")
|
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||||
.order("id")
|
.where("event_type", "in", [...types])
|
||||||
),
|
.where("event_date", ">=", yearStart)
|
||||||
// Entries/exits count history events, which is what the linked report
|
.where("event_date", "<=", yearEnd)
|
||||||
// counts too. `entry_date` would also sweep up rehires, whose event is
|
.executeTakeFirst();
|
||||||
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
|
||||||
// different numbers for the same year.
|
const [
|
||||||
supabase
|
drafts,
|
||||||
.from("employee_history")
|
staffRows,
|
||||||
.select("id", { count: "exact", head: true })
|
hiresYtd,
|
||||||
.in("event_type", ["Eintritt", "Wiedereintritt"])
|
exitsYtd,
|
||||||
.gte("event_date", yearStart)
|
openPositions,
|
||||||
.lte("event_date", yearEnd),
|
orgMaps,
|
||||||
supabase
|
placements,
|
||||||
.from("employee_history")
|
upcomingHires,
|
||||||
.select("id", { count: "exact", head: true })
|
upcomingExits,
|
||||||
.eq("event_type", "Austritt")
|
upcomingReturns,
|
||||||
.gte("event_date", yearStart)
|
history,
|
||||||
.lte("event_date", yearEnd),
|
] = await Promise.all([
|
||||||
loadOpenPositions(supabase),
|
userId
|
||||||
loadOrgMaps(supabase),
|
? tx
|
||||||
loadPlacements(supabase, { asOf: today }),
|
.selectFrom("hire_drafts")
|
||||||
supabase
|
.select(["id", "step", "payload", "updated_at"])
|
||||||
.from("employees")
|
.where("created_by", "=", userId)
|
||||||
.select("id, first_name, last_name, entry_date")
|
.orderBy("updated_at", "desc")
|
||||||
.eq("status", "Geplant")
|
.execute()
|
||||||
.gte("entry_date", today)
|
: Promise.resolve([]),
|
||||||
.lte("entry_date", in60Iso),
|
|
||||||
supabase
|
tx
|
||||||
.from("employees")
|
.selectFrom("employees")
|
||||||
.select("id, first_name, last_name, exit_date")
|
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
|
||||||
.not("exit_date", "is", null)
|
.orderBy("id")
|
||||||
.gte("exit_date", today)
|
.execute(),
|
||||||
.lte("exit_date", in60Iso),
|
|
||||||
supabase
|
// Entries/exits count history events, which is what the linked report
|
||||||
.from("employees")
|
// counts too. `entry_date` would also sweep up rehires, whose event is
|
||||||
.select("id, first_name, last_name, karenz_return_date")
|
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
||||||
.eq("status", "Karenz")
|
// different numbers for the same year.
|
||||||
.not("karenz_return_date", "is", null)
|
countIn(["Eintritt", "Wiedereintritt"]),
|
||||||
.gte("karenz_return_date", today)
|
countIn(["Austritt"]),
|
||||||
.lte("karenz_return_date", in60Iso),
|
|
||||||
supabase
|
loadOpenPositions(tx),
|
||||||
.from("employee_history")
|
loadOrgMaps(tx),
|
||||||
.select("id, employee_id, event_date, event_type, description")
|
loadPlacements(tx, { asOf: today }),
|
||||||
.order("event_date", { ascending: false })
|
|
||||||
.order("created_at", { ascending: false })
|
tx
|
||||||
.limit(10),
|
.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
|
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
||||||
// active, and is counted by its own tile instead. FTE follows the same
|
// 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 };
|
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
|
||||||
const upcoming: UpcomingItem[] = [
|
const upcoming: UpcomingItem[] = [
|
||||||
...(upcomingHiresRes.data ?? []).map((e) => ({
|
...(upcomingHires).map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
date: e.entry_date,
|
date: e.entry_date,
|
||||||
kind: "hire" as const,
|
kind: "hire" as const,
|
||||||
})),
|
})),
|
||||||
...(upcomingExitsRes.data ?? []).map((e) => ({
|
...(upcomingExits).map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
date: e.exit_date!,
|
date: e.exit_date!,
|
||||||
kind: "exit" as const,
|
kind: "exit" as const,
|
||||||
})),
|
})),
|
||||||
...(upcomingReturnsRes.data ?? []).map((e) => ({
|
...(upcomingReturns).map((e) => ({
|
||||||
id: e.id,
|
id: e.id,
|
||||||
label: `${e.first_name} ${e.last_name}`,
|
label: `${e.first_name} ${e.last_name}`,
|
||||||
date: e.karenz_return_date!,
|
date: e.karenz_return_date!,
|
||||||
@@ -188,12 +225,6 @@ export default async function DashboardPage() {
|
|||||||
.sort((a, b) => a.date.localeCompare(b.date))
|
.sort((a, b) => a.date.localeCompare(b.date))
|
||||||
.slice(0, 8);
|
.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
|
// Each tile links to the view that shows what it counts, with the filters
|
||||||
// pre-applied.
|
// 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: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
||||||
{
|
{
|
||||||
label: "Eintritte (Jahr)",
|
label: "Eintritte (Jahr)",
|
||||||
value: hiresYtdRes.count ?? 0,
|
value: hiresYtd,
|
||||||
tone: "success",
|
tone: "success",
|
||||||
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Austritte (Jahr)",
|
label: "Austritte (Jahr)",
|
||||||
value: exitsYtdRes.count ?? 0,
|
value: exitsYtd,
|
||||||
tone: "danger",
|
tone: "danger",
|
||||||
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
||||||
},
|
},
|
||||||
@@ -300,14 +331,14 @@ export default async function DashboardPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
||||||
<ul className="flex flex-col divide-y divide-border-subtle">
|
<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">
|
<li key={h.id} className="flex gap-2.5 py-2.5">
|
||||||
{/* Dot aligned to the first line of text, not centred on the
|
{/* Dot aligned to the first line of text, not centred on the
|
||||||
whole row, so it stays put as descriptions wrap. */}
|
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 />
|
<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="min-w-0 flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-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)}`}>
|
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
||||||
{h.event_type}
|
{h.event_type}
|
||||||
</span>
|
</span>
|
||||||
@@ -316,7 +347,7 @@ export default async function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,21 +3,23 @@ import { PositionsPageClient } from "@/components/positions/PositionsPageClient"
|
|||||||
import { daysBetweenIso } from "@/lib/format";
|
import { daysBetweenIso } from "@/lib/format";
|
||||||
import { loadOrgMaps } from "@/lib/org";
|
import { loadOrgMaps } from "@/lib/org";
|
||||||
import { loadOpenPositions } from "@/lib/positions";
|
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() {
|
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([
|
const withChief = new Set(chiefRows.map((r) => r.org_unit_id));
|
||||||
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 units: UnitOption[] = orgMaps.unitList.map((u) => ({
|
const units: UnitOption[] = orgMaps.unitList.map((u) => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
name: u.name,
|
name: u.name,
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import {
|
|||||||
totalForRows,
|
totalForRows,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
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 = {
|
type SearchParams = {
|
||||||
mode?: string;
|
mode?: string;
|
||||||
@@ -35,7 +36,6 @@ type SearchParams = {
|
|||||||
|
|
||||||
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
export default async function ReportsPage({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
const supabase = await createClient();
|
|
||||||
const mode = parseMode(params.mode);
|
const mode = parseMode(params.mode);
|
||||||
|
|
||||||
// Both modes are parsed up front so the data load can start before
|
// 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
|
// 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
|
// costs about as much as the query itself, which made this page's three
|
||||||
// sequential waves its dominant cost.
|
// sequential waves its dominant cost.
|
||||||
const [{ lookups, divisions, locations }, { data: userRes }, events, employees] = await Promise.all([
|
const userId = await currentUserId();
|
||||||
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 user = userRes.user;
|
// Alles in einer Transaktion — dort gilt der Sitzungskontext, und der
|
||||||
// Still a wave of its own: it needs the user id the call above resolves.
|
// Lesestand ist über alle Abfragen hinweg derselbe. Vorher waren es drei
|
||||||
const { data: savedReports } = user
|
// Wellen nacheinander, was gegen eine entfernte Datenbank der teuerste
|
||||||
? await supabase.from("saved_reports").select("id, name, config").eq("created_by", user.id).order("created_at", { ascending: false })
|
// Teil dieser Seite war.
|
||||||
: { data: [] };
|
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") {
|
if (mode === "events") {
|
||||||
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
|
const rows = aggregateEvents(events, eventGroup, eventSplit, lookups);
|
||||||
@@ -100,7 +110,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
recordCount={events.length}
|
recordCount={events.length}
|
||||||
divisions={divisions}
|
divisions={divisions}
|
||||||
locations={locations}
|
locations={locations}
|
||||||
savedReports={savedReports ?? []}
|
savedReports={savedReports}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
@@ -127,7 +137,7 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom
|
|||||||
recordCount={employees.length}
|
recordCount={employees.length}
|
||||||
divisions={divisions}
|
divisions={divisions}
|
||||||
locations={locations}
|
locations={locations}
|
||||||
savedReports={savedReports ?? []}
|
savedReports={savedReports}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import { createAdminClient } from "@/lib/supabase/admin";
|
import { asSystem } from "@/lib/db";
|
||||||
|
import { callFunction } from "@/lib/db/rpc";
|
||||||
|
|
||||||
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
|
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
|
||||||
// ändern with a future "Wirksam ab" date) once their date has arrived — see
|
// ändern with a future "Wirksam ab" date) once their date has arrived — see
|
||||||
@@ -13,13 +14,15 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
|
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const supabase = createAdminClient();
|
// Kein privilegierter Zugang mehr: derselbe Datenbankbenutzer ohne
|
||||||
const { data, error } = await supabase.rpc("apply_due_pending_changes");
|
// BYPASSRLS wie überall. apply_due_pending_changes ist SECURITY DEFINER
|
||||||
|
// und prüft selbst, was sie tut — der Dienstschlüssel, der RLS aushebelte,
|
||||||
if (error) {
|
// ist damit entfallen.
|
||||||
console.error("apply_due_pending_changes failed:", error);
|
try {
|
||||||
|
const applied = await asSystem((tx) => callFunction(tx, "apply_due_pending_changes"));
|
||||||
|
return NextResponse.json({ applied });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("apply_due_pending_changes failed:", err);
|
||||||
return NextResponse.json({ error: "Interner Fehler." }, { status: 500 });
|
return NextResponse.json({ error: "Interner Fehler." }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ applied: data });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,11 @@ import { statusLabel } from "@/lib/absence";
|
|||||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||||
import { todayIso } from "@/lib/format";
|
import { todayIso } from "@/lib/format";
|
||||||
import { subtreeOf } from "@/lib/org";
|
import { subtreeOf } from "@/lib/org";
|
||||||
import { loadPlacements, loadReportingLines } from "@/lib/placement";
|
import { loadPlacements, loadReportingLineMap } from "@/lib/placement";
|
||||||
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
|
import { deriveStatusAsOf, parseIsoDateParam, parseStatuses, type OrgLookups } from "@/lib/reports";
|
||||||
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
import { loadDependentsCounts, loadOrgLookups, type ReportFilters } from "@/lib/reports-data";
|
||||||
import { requireHrUser } from "@/lib/supabase/auth";
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||||
import { fetchAllRows } from "@/lib/supabase/query";
|
import { withUser } from "@/lib/db";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
|
||||||
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
|
import type { Database, EmploymentType, Weekday } from "@/lib/supabase/types";
|
||||||
|
|
||||||
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
|
// Die Rohzeile plus die Einordnung, die nicht mehr auf ihr steht: sie kommt
|
||||||
@@ -26,9 +25,8 @@ type EmployeeRow = Database["public"]["Tables"]["employees"]["Row"] & {
|
|||||||
// filtering happens against the *derived* status as of that date rather
|
// filtering happens against the *derived* status as of that date rather
|
||||||
// than the live `status` column — see deriveStatusAsOf.
|
// than the live `status` column — see deriveStatusAsOf.
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const supabase = await createClient();
|
const gate = await requireHrUser();
|
||||||
const denied = await requireHrUser(supabase);
|
if ("denied" in gate) return gate.denied;
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const params = request.nextUrl.searchParams;
|
const params = request.nextUrl.searchParams;
|
||||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||||
@@ -44,22 +42,37 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
const stichtag = asOf ?? todayIso();
|
const stichtag = asOf ?? todayIso();
|
||||||
|
|
||||||
function employeeQuery() {
|
const { employees, lookups, orgMaps, allEmployees, dependentsCounts, placements, lines } = await withUser(
|
||||||
let query = supabase.from("employees").select("*").order("last_name").order("id");
|
gate.userId,
|
||||||
if (filters.location) query = query.eq("location_id", filters.location);
|
async (tx) => {
|
||||||
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
function employeeQuery() {
|
||||||
if (!asOf) query = query.in("status", statuses);
|
let q = tx.selectFrom("employees").selectAll().orderBy("last_name").orderBy("id");
|
||||||
return query;
|
if (filters.location) q = q.where("location_id", "=", filters.location);
|
||||||
}
|
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
|
||||||
|
if (!asOf) q = q.where("status", "in", statuses);
|
||||||
|
return q;
|
||||||
|
}
|
||||||
|
|
||||||
const [employees, { lookups, orgMaps }, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
|
const [employees, lookupResult, allEmployees, dependentsCounts, placements, lines] = await Promise.all([
|
||||||
fetchAllRows(employeeQuery),
|
employeeQuery().execute(),
|
||||||
loadOrgLookups(supabase),
|
loadOrgLookups(tx),
|
||||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
|
tx.selectFrom("employees").select(["id", "first_name", "last_name"]).orderBy("id").execute(),
|
||||||
loadDependentsCounts(supabase),
|
loadDependentsCounts(tx),
|
||||||
loadPlacements(supabase, { asOf: stichtag }),
|
loadPlacements(tx, { asOf: stichtag }),
|
||||||
loadReportingLines(supabase, stichtag),
|
loadReportingLineMap(tx, stichtag),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
employees,
|
||||||
|
lookups: lookupResult.lookups,
|
||||||
|
orgMaps: lookupResult.orgMaps,
|
||||||
|
allEmployees,
|
||||||
|
dependentsCounts,
|
||||||
|
placements,
|
||||||
|
lines,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
const managerName = new Map(allEmployees.map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
|
||||||
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
|
// Der Einheitenfilter meint den ganzen Teilbaum — sonst enthielte ein
|
||||||
|
|||||||
@@ -2,32 +2,34 @@ import { NextResponse, type NextRequest } from "next/server";
|
|||||||
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
import { exportFilename, exportResponseHeaders, toCsv, toXlsx, type ExportColumn } from "@/lib/export";
|
||||||
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
|
import { EVENT_TYPE_LABELS, parseEventDateParam, parseEventType, type OrgLookups, type ReportEvent } from "@/lib/reports";
|
||||||
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
|
import { loadEventHistory, loadOrgLookups } from "@/lib/reports-data";
|
||||||
import { requireHrUser } from "@/lib/supabase/auth";
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
// Full raw event-log dump — one row per employee_history entry in the
|
// Full raw event-log dump — one row per employee_history entry in the
|
||||||
// selected period (default: current year), every event type unless one is
|
// selected period (default: current year), every event type unless one is
|
||||||
// picked, org columns resolved from each affected employee's current
|
// picked, org columns resolved from each affected employee's current
|
||||||
// placement (see loadEventHistory).
|
// placement (see loadEventHistory).
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const supabase = await createClient();
|
const gate = await requireHrUser();
|
||||||
const denied = await requireHrUser(supabase);
|
if ("denied" in gate) return gate.denied;
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const params = request.nextUrl.searchParams;
|
const params = request.nextUrl.searchParams;
|
||||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||||
const eventType = parseEventType(params.get("eventType"));
|
const eventType = parseEventType(params.get("eventType"));
|
||||||
|
|
||||||
const [{ lookups }, events] = await Promise.all([
|
const { lookups, events } = await withUser(gate.userId, async (tx) => {
|
||||||
loadOrgLookups(supabase),
|
const [{ lookups }, events] = await Promise.all([
|
||||||
loadEventHistory(supabase, {
|
loadOrgLookups(tx),
|
||||||
eventType: eventType ?? undefined,
|
loadEventHistory(tx, {
|
||||||
division: params.get("division") ?? undefined,
|
eventType: eventType ?? undefined,
|
||||||
location: params.get("location") ?? undefined,
|
division: params.get("division") ?? undefined,
|
||||||
from: parseEventDateParam(params.get("from")),
|
location: params.get("location") ?? undefined,
|
||||||
to: parseEventDateParam(params.get("to")),
|
from: parseEventDateParam(params.get("from")),
|
||||||
}),
|
to: parseEventDateParam(params.get("to")),
|
||||||
]);
|
}),
|
||||||
|
]);
|
||||||
|
return { lookups, events };
|
||||||
|
});
|
||||||
|
|
||||||
const columns = eventExportColumns(lookups);
|
const columns = eventExportColumns(lookups);
|
||||||
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);
|
const filename = exportFilename(`ereignisse-${eventType ?? "alle"}`, format);
|
||||||
|
|||||||
@@ -24,59 +24,64 @@ import {
|
|||||||
type ReportRow,
|
type ReportRow,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
import { loadEventHistory, loadOrgLookups, loadSnapshotEmployees } from "@/lib/reports-data";
|
||||||
import { requireHrUser } from "@/lib/supabase/auth";
|
import { requireHrUser } from "@/lib/auth/require-hr";
|
||||||
import { createClient } from "@/lib/supabase/server";
|
import { withUser } from "@/lib/db";
|
||||||
|
|
||||||
// Exports exactly the pivot table currently on screen (same mode/measure or
|
// Exports exactly the pivot table currently on screen (same mode/measure or
|
||||||
// event-type/group/split/filters, read from the query string the client
|
// event-type/group/split/filters, read from the query string the client
|
||||||
// already keeps in the URL) as a flat table — one row per group, one column
|
// already keeps in the URL) as a flat table — one row per group, one column
|
||||||
// per split value if a split is active.
|
// per split value if a split is active.
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
const supabase = await createClient();
|
const gate = await requireHrUser();
|
||||||
const denied = await requireHrUser(supabase);
|
if ("denied" in gate) return gate.denied;
|
||||||
if (denied) return denied;
|
|
||||||
|
|
||||||
const params = request.nextUrl.searchParams;
|
const params = request.nextUrl.searchParams;
|
||||||
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
const format = params.get("format") === "xlsx" ? "xlsx" : "csv";
|
||||||
const mode = parseMode(params.get("mode"));
|
const mode = parseMode(params.get("mode"));
|
||||||
const { lookups } = await loadOrgLookups(supabase);
|
// Eine Transaktion für Nachschlagewerte und Daten: dort gilt der
|
||||||
|
// Sitzungskontext, und beide sehen denselben Lesestand.
|
||||||
|
const { rows, columns, filenameBase } = await withUser(gate.userId, async (tx) => {
|
||||||
|
const { lookups } = await loadOrgLookups(tx);
|
||||||
|
let rows: ReportRow[];
|
||||||
|
let columns: ExportColumn<ReportRow>[];
|
||||||
|
let filenameBase: string;
|
||||||
|
|
||||||
let rows: ReportRow[];
|
if (mode === "events") {
|
||||||
let columns: ExportColumn<ReportRow>[];
|
const group = parseEventGroupDimension(params.get("group"));
|
||||||
let filenameBase: string;
|
const split = parseEventSplitDimension(params.get("split"));
|
||||||
|
const eventType = parseEventType(params.get("eventType"));
|
||||||
|
const events = await loadEventHistory(tx, {
|
||||||
|
eventType: eventType ?? undefined,
|
||||||
|
division: params.get("division") ?? undefined,
|
||||||
|
location: params.get("location") ?? undefined,
|
||||||
|
from: parseEventDateParam(params.get("from")),
|
||||||
|
to: parseEventDateParam(params.get("to")),
|
||||||
|
});
|
||||||
|
rows = aggregateEvents(events, group, split, lookups);
|
||||||
|
columns = eventReportColumns(rows, group, split, sumValues(rows));
|
||||||
|
filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`;
|
||||||
|
} else {
|
||||||
|
const measure = parseMeasure(params.get("measure"));
|
||||||
|
const group = parseGroupDimension(params.get("group"));
|
||||||
|
const split = parseSplitDimension(params.get("split"));
|
||||||
|
const asOf = parseIsoDateParam(params.get("asOf"));
|
||||||
|
const employees = await loadSnapshotEmployees(tx, {
|
||||||
|
division: params.get("division") ?? undefined,
|
||||||
|
location: params.get("location") ?? undefined,
|
||||||
|
status: params.get("status") ?? undefined,
|
||||||
|
employment: params.get("employment") ?? undefined,
|
||||||
|
asOf,
|
||||||
|
});
|
||||||
|
rows = aggregateReport(employees, measure, group, split, lookups, asOf);
|
||||||
|
columns = snapshotReportColumns(rows, measure, group, split, totalForRows(rows, measure));
|
||||||
|
filenameBase = `bericht-${measure}-${group}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "events") {
|
return { rows, columns, filenameBase };
|
||||||
const group = parseEventGroupDimension(params.get("group"));
|
});
|
||||||
const split = parseEventSplitDimension(params.get("split"));
|
|
||||||
const eventType = parseEventType(params.get("eventType"));
|
|
||||||
const events = await loadEventHistory(supabase, {
|
|
||||||
eventType: eventType ?? undefined,
|
|
||||||
division: params.get("division") ?? undefined,
|
|
||||||
location: params.get("location") ?? undefined,
|
|
||||||
from: parseEventDateParam(params.get("from")),
|
|
||||||
to: parseEventDateParam(params.get("to")),
|
|
||||||
});
|
|
||||||
rows = aggregateEvents(events, group, split, lookups);
|
|
||||||
columns = eventReportColumns(rows, group, split, sumValues(rows));
|
|
||||||
filenameBase = `ereignisse-${eventType ?? "alle"}-${group}`;
|
|
||||||
} else {
|
|
||||||
const measure = parseMeasure(params.get("measure"));
|
|
||||||
const group = parseGroupDimension(params.get("group"));
|
|
||||||
const split = parseSplitDimension(params.get("split"));
|
|
||||||
const asOf = parseIsoDateParam(params.get("asOf"));
|
|
||||||
const employees = await loadSnapshotEmployees(supabase, {
|
|
||||||
division: params.get("division") ?? undefined,
|
|
||||||
location: params.get("location") ?? undefined,
|
|
||||||
status: params.get("status") ?? undefined,
|
|
||||||
employment: params.get("employment") ?? undefined,
|
|
||||||
asOf,
|
|
||||||
});
|
|
||||||
rows = aggregateReport(employees, measure, group, split, lookups, asOf);
|
|
||||||
columns = snapshotReportColumns(rows, measure, group, split, totalForRows(rows, measure));
|
|
||||||
filenameBase = `bericht-${measure}-${group}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filename = exportFilename(filenameBase, format);
|
const filename = exportFilename(filenameBase, format);
|
||||||
|
|
||||||
const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns);
|
const body = format === "xlsx" ? await toXlsx(rows, columns, "Bericht") : toCsv(rows, columns);
|
||||||
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
|
// TS 5.9's Uint8Array<ArrayBufferLike> vs DOM's BlobPart/ArrayBuffer<> generic
|
||||||
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.
|
// mismatch (microsoft/TypeScript#59417) — a real Uint8Array works fine here.
|
||||||
|
|||||||
26
lib/auth/require-hr.ts
Normal file
26
lib/auth/require-hr.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { withUser } from "@/lib/db";
|
||||||
|
import { currentUserId } from "./session";
|
||||||
|
|
||||||
|
// Route Handlers under /api/export/* are outside the App Router layout tree,
|
||||||
|
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
|
||||||
|
// re-establish that the caller is an active HR user itself. RLS is still the
|
||||||
|
// real boundary (an unauthorized session simply reads nothing); this exists
|
||||||
|
// so those routes answer 401/403 instead of handing back an empty workbook.
|
||||||
|
|
||||||
|
export type HrGate = { denied: NextResponse } | { userId: string };
|
||||||
|
|
||||||
|
export async function requireHrUser(): Promise<HrGate> {
|
||||||
|
const userId = await currentUserId();
|
||||||
|
if (!userId) return { denied: NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 }) };
|
||||||
|
|
||||||
|
const profile = await withUser(userId, (tx) =>
|
||||||
|
tx.selectFrom("profiles").select(["role", "is_active"]).where("id", "=", userId).executeTakeFirst()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (profile?.role !== "hr" || profile.is_active !== true) {
|
||||||
|
return { denied: NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 }) };
|
||||||
|
}
|
||||||
|
return { userId };
|
||||||
|
}
|
||||||
34
lib/auth/session.ts
Normal file
34
lib/auth/session.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
// Der einzige Ort, an dem die Kennung der angemeldeten Person herkommt.
|
||||||
|
//
|
||||||
|
// Heute liefert sie GoTrue, morgen Auth.js mit Entra ID. Weil alles andere
|
||||||
|
// nur noch `currentUserId()` aufruft und den Wert an withUser() weiterreicht,
|
||||||
|
// ist der Wechsel des Anmeldeverfahrens eine Änderung an dieser Datei — nicht
|
||||||
|
// an fünfzig Aufrufstellen.
|
||||||
|
//
|
||||||
|
// Dass das aufgeht, liegt an einer Eigenschaft des Übergangs: profiles.id ist
|
||||||
|
// heute die auth.users.id. Die Kennung, die hier herauskommt, passt also
|
||||||
|
// bereits auf das, was app_current_user_id() in der Datenbank erwartet.
|
||||||
|
|
||||||
|
export async function currentUserId(): Promise<string | null> {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
return user?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie currentUserId(), bricht aber ab, statt null zu liefern.
|
||||||
|
*
|
||||||
|
* Für Stellen, die ohne angemeldete Person keinen Sinn ergeben. Die
|
||||||
|
* Absicherung hängt trotzdem nicht daran: ohne Kontext geben die
|
||||||
|
* RLS-Policies nichts zurück, unabhängig davon, was der Anwendungscode tut.
|
||||||
|
*/
|
||||||
|
export async function requireUserId(): Promise<string> {
|
||||||
|
const id = await currentUserId();
|
||||||
|
if (!id) throw new Error("Nicht angemeldet.");
|
||||||
|
return id;
|
||||||
|
}
|
||||||
56
lib/db/rpc.ts
Normal file
56
lib/db/rpc.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { sql, withUser, type Tx } from "./index";
|
||||||
|
import type { Database } from "@/lib/supabase/types";
|
||||||
|
|
||||||
|
// Aufruf einer Datenbankfunktion.
|
||||||
|
//
|
||||||
|
// Die Geschäftslogik liegt in PL/pgSQL — Eintritt, Versetzung, Austritt und
|
||||||
|
// die übrigen zehn Mutationen. Daran ändert der Wechsel des Zugriffswegs
|
||||||
|
// nichts: es fällt nur die API-Schicht dazwischen weg. Aufgerufen wird die
|
||||||
|
// Funktion jetzt unmittelbar, innerhalb der Transaktion, in der auch der
|
||||||
|
// Sitzungskontext gilt — ohne den würde require_hr_admin() darin abweisen.
|
||||||
|
|
||||||
|
export type MutationFn = keyof Database["public"]["Functions"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruft `fn(payload)` innerhalb der laufenden Transaktion auf.
|
||||||
|
*
|
||||||
|
* `payload` weglassen für die Funktionen ohne Argument —
|
||||||
|
* apply_due_pending_changes() ist die einzige. Mit einem jsonb-Argument
|
||||||
|
* aufgerufen fände Postgres keine passende Signatur.
|
||||||
|
*/
|
||||||
|
export async function callFunction(tx: Tx, fn: MutationFn, payload?: Record<string, unknown>): Promise<unknown> {
|
||||||
|
// Der Funktionsname stammt aus einer geschlossenen Aufzählung, nie aus
|
||||||
|
// einer Eingabe — sonst wäre die Verkettung hier eine Einladung.
|
||||||
|
const name = sql.raw(`"${fn}"`);
|
||||||
|
const query =
|
||||||
|
payload === undefined
|
||||||
|
? sql<{ result: unknown }>`select ${name}() as result`
|
||||||
|
: sql<{ result: unknown }>`select ${name}(${sql.val(JSON.stringify(payload))}::jsonb) as result`;
|
||||||
|
const result = await query.execute(tx);
|
||||||
|
return result.rows[0]?.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionResult = { success: boolean; error?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Mutation im Namen der angemeldeten Person, mit der üblichen
|
||||||
|
* Fehlerbehandlung für Server Actions.
|
||||||
|
*
|
||||||
|
* Die Prüfung der Berechtigung passiert in der Funktion selbst
|
||||||
|
* (require_hr_admin) und unabhängig davon in den RLS-Policies — nicht hier.
|
||||||
|
*/
|
||||||
|
export async function runMutation(
|
||||||
|
userId: string | null,
|
||||||
|
fn: MutationFn,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): Promise<ActionResult> {
|
||||||
|
try {
|
||||||
|
await withUser(userId, (tx) => callFunction(tx, fn, payload));
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
// Die Meldungen der Funktionen sind für die Oberfläche geschrieben
|
||||||
|
// („Diese Planstelle ist bereits besetzt.") und werden durchgereicht.
|
||||||
|
return { success: false, error: err instanceof Error ? err.message : "Unbekannter Fehler." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Expression, ExpressionBuilder, SqlBool } from "kysely";
|
||||||
|
import type { Schema } from "./db/schema";
|
||||||
import type { EmploymentStatus } from "./supabase/types";
|
import type { EmploymentStatus } from "./supabase/types";
|
||||||
|
|
||||||
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
|
// The SQL counterpart of deriveStatusAsOf() in lib/reports.ts.
|
||||||
@@ -19,58 +21,59 @@ import type { EmploymentStatus } from "./supabase/types";
|
|||||||
// tests/integration/employee-status-filter.test.ts asserts the two agree
|
// tests/integration/employee-status-filter.test.ts asserts the two agree
|
||||||
// against a real database, which is the only place that can prove it.
|
// against a real database, which is the only place that can prove it.
|
||||||
|
|
||||||
type Filterable = {
|
type Eb = ExpressionBuilder<Schema, "employees">;
|
||||||
gt: (column: string, value: string) => Filterable;
|
|
||||||
lte: (column: string, value: string) => Filterable;
|
|
||||||
gte: (column: string, value: string) => Filterable;
|
|
||||||
or: (filters: string) => Filterable;
|
|
||||||
is: (column: string, value: null) => Filterable;
|
|
||||||
not: (column: string, operator: string, value: null) => Filterable;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** True once the person has started and has not left yet. */
|
/** True once the person has started and has not left yet. */
|
||||||
function employed<Q extends Filterable>(query: Q, asOf: string): Q {
|
function employed(eb: Eb, asOf: string): Expression<SqlBool> {
|
||||||
return query.lte("entry_date", asOf).or(`exit_date.is.null,exit_date.gt.${asOf}`) as Q;
|
return eb.and([eb("entry_date", "<=", asOf), eb.or([eb("exit_date", "is", null), eb("exit_date", ">", asOf)])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Narrows a PostgREST query to the employees whose *derived* status on
|
* Die Bedingung für die Menge, deren *abgeleiteter* Status am Stichtag einer
|
||||||
* `asOf` is one of `statuses`. Only the combinations the UI offers are
|
* der genannten ist — oder null, wenn nicht eingeschränkt werden soll.
|
||||||
* supported; anything else is left unfiltered rather than silently applying
|
*
|
||||||
* a wrong one.
|
* Nur die Kombinationen, die die Oberfläche anbietet, sind abgedeckt. Für
|
||||||
|
* alles andere kommt null zurück: lieber nicht filtern als falsch filtern.
|
||||||
*/
|
*/
|
||||||
export function applyDerivedStatusFilter<Q extends Filterable>(query: Q, statuses: EmploymentStatus[], asOf: string): Q {
|
export function derivedStatusFilter(eb: Eb, statuses: EmploymentStatus[], asOf: string): Expression<SqlBool> | null {
|
||||||
const wanted = new Set(statuses);
|
const wanted = new Set(statuses);
|
||||||
if (wanted.size === 0) return query;
|
if (wanted.size === 0) return null;
|
||||||
|
|
||||||
// A single non-employed status is a straight date comparison.
|
// A single non-employed status is a straight date comparison.
|
||||||
if (wanted.size === 1 && wanted.has("Geplant")) return query.gt("entry_date", asOf) as Q;
|
if (wanted.size === 1 && wanted.has("Geplant")) return eb("entry_date", ">", asOf);
|
||||||
if (wanted.size === 1 && wanted.has("Ausgetreten")) return query.not("exit_date", "is", null).lte("exit_date", asOf) as Q;
|
if (wanted.size === 1 && wanted.has("Ausgetreten")) {
|
||||||
|
return eb.and([eb("exit_date", "is not", null), eb("exit_date", "<=", asOf)]);
|
||||||
|
}
|
||||||
|
|
||||||
const wantsAktiv = wanted.has("Aktiv");
|
const wantsAktiv = wanted.has("Aktiv");
|
||||||
const wantsKarenz = wanted.has("Karenz");
|
const wantsKarenz = wanted.has("Karenz");
|
||||||
|
|
||||||
if (wantsAktiv && wantsKarenz && wanted.size === 2) {
|
// Everyone employed today, whether or not they are on leave.
|
||||||
// Everyone employed today, whether or not they are on leave.
|
if (wantsAktiv && wantsKarenz && wanted.size === 2) return employed(eb, asOf);
|
||||||
return employed(query, asOf);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
|
if (wantsKarenz && !wantsAktiv && wanted.size === 1) {
|
||||||
return employed(query, asOf)
|
return eb.and([
|
||||||
.not("karenz_start_date", "is", null)
|
employed(eb, asOf),
|
||||||
.lte("karenz_start_date", asOf)
|
eb("karenz_start_date", "is not", null),
|
||||||
.or(`karenz_return_date.is.null,karenz_return_date.gt.${asOf}`) as Q;
|
eb("karenz_start_date", "<=", asOf),
|
||||||
|
eb.or([eb("karenz_return_date", "is", null), eb("karenz_return_date", ">", asOf)]),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
|
if (wantsAktiv && !wantsKarenz && wanted.size === 1) {
|
||||||
// Employed but *not* inside a karenz window: either no start date, a
|
// Employed but *not* inside a karenz window: either no start date, a
|
||||||
// start still ahead, or a return that has already happened.
|
// start still ahead, or a return that has already happened.
|
||||||
return employed(query, asOf).or(
|
return eb.and([
|
||||||
`karenz_start_date.is.null,karenz_start_date.gt.${asOf},karenz_return_date.lte.${asOf}`
|
employed(eb, asOf),
|
||||||
) as Q;
|
eb.or([
|
||||||
|
eb("karenz_start_date", "is", null),
|
||||||
|
eb("karenz_start_date", ">", asOf),
|
||||||
|
eb("karenz_return_date", "<=", asOf),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mixed selections spanning employed and non-employed states have no UI
|
// Mixed selections spanning employed and non-employed states have no UI
|
||||||
// path today; filtering on a guess would be worse than not filtering.
|
// path today; filtering on a guess would be worse than not filtering.
|
||||||
return query;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
40
lib/notes.ts
40
lib/notes.ts
@@ -1,26 +1,32 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { Tx } from "./db";
|
||||||
import { fetchAllRows } from "./supabase/query";
|
|
||||||
import type { Database } from "./supabase/types";
|
import type { Database } from "./supabase/types";
|
||||||
|
|
||||||
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
|
export type OpenNote = Database["public"]["Tables"]["employee_notes"]["Row"] & {
|
||||||
employeeName: string;
|
employeeName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// "Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
|
// „Meine Notizen" (Topbar-Glocke): das geteilte, mitarbeiterübergreifende
|
||||||
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon wer
|
// Postfach aller noch nicht erledigten HR-Notizen — unabhängig davon, wer sie
|
||||||
// sie verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt). Zwei
|
// verfasst hat oder zu wem sie gehören (mit Nutzer abgestimmt).
|
||||||
// einfache Queries, in JS gemerged — gleiches Muster wie loadEventHistory
|
//
|
||||||
// in lib/reports-data.ts, da der handgeschriebene Database-Typ keine
|
// Früher zwei Abfragen, in JavaScript zusammengeführt, weil die API-Schicht
|
||||||
// relationalen Embeddings für eine einzelne verschachtelte Query kennt.
|
// für eine einzelne verschachtelte Abfrage keine Verknüpfung anbot. Am
|
||||||
export async function loadOpenNotes(supabase: SupabaseClient<Database>): Promise<OpenNote[]> {
|
// direkten Zugang ist es schlicht ein Join.
|
||||||
const [{ data: notes }, employees] = await Promise.all([
|
export async function loadOpenNotes(tx: Tx): Promise<OpenNote[]> {
|
||||||
supabase.from("employee_notes").select("*").eq("done", false).order("created_at", { ascending: false }),
|
const rows = await tx
|
||||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name").order("id")),
|
.selectFrom("employee_notes as n")
|
||||||
]);
|
.leftJoin("employees as e", "e.id", "n.employee_id")
|
||||||
|
.selectAll("n")
|
||||||
|
.select(["e.first_name", "e.last_name"])
|
||||||
|
.where("n.done", "=", false)
|
||||||
|
.orderBy("n.created_at", "desc")
|
||||||
|
.execute();
|
||||||
|
|
||||||
const employeeById = new Map(employees.map((e) => [e.id, e]));
|
return rows.map((row) => {
|
||||||
return (notes ?? []).map((n) => {
|
const { first_name, last_name, ...note } = row;
|
||||||
const emp = employeeById.get(n.employee_id);
|
return {
|
||||||
return { ...n, employeeName: emp ? `${emp.first_name} ${emp.last_name}` : "Unbekannt" };
|
...(note as Database["public"]["Tables"]["employee_notes"]["Row"]),
|
||||||
|
employeeName: first_name && last_name ? `${first_name} ${last_name}` : "Unbekannt",
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
16
lib/org.ts
16
lib/org.ts
@@ -1,4 +1,4 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { Tx } from "./db";
|
||||||
import type { Database } from "./supabase/types";
|
import type { Database } from "./supabase/types";
|
||||||
|
|
||||||
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher
|
// Die Organisation ist ein Baum, keine drei Tabellen mehr. Alles, was früher
|
||||||
@@ -31,13 +31,17 @@ export type OrgMaps = {
|
|||||||
|
|
||||||
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
|
// Die Referenzdaten sind winzig (60 Einheiten, 5 Standorte) — sie werden
|
||||||
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
|
// ganz geladen und im Speicher verknüpft, statt je Zeile nachzuschlagen.
|
||||||
export async function loadOrgMaps(supabase: SupabaseClient<Database>): Promise<OrgMaps> {
|
export async function loadOrgMaps(tx: Tx): Promise<OrgMaps> {
|
||||||
const [{ data: units }, { data: locations }] = await Promise.all([
|
const [units, locations] = await Promise.all([
|
||||||
supabase.from("org_units").select("id, org_number, name, parent_id, unit_type").order("org_number"),
|
tx
|
||||||
supabase.from("locations").select("*").order("name"),
|
.selectFrom("org_units")
|
||||||
|
.select(["id", "org_number", "name", "parent_id", "unit_type"])
|
||||||
|
.orderBy("org_number")
|
||||||
|
.execute(),
|
||||||
|
tx.selectFrom("locations").selectAll().orderBy("name").execute(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return buildOrgMaps((units ?? []) as OrgUnit[], locations ?? []);
|
return buildOrgMaps(units as OrgUnit[], locations as Location[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */
|
/** Der reine Teil: aus den Zeilen den Baum bauen, ohne Datenbank. */
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
||||||
import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types";
|
import type { OrgEmployee, OrgVacancy } from "@/components/orgchart/types";
|
||||||
|
import type { Tx } from "./db";
|
||||||
import { todayIso } from "./format";
|
import { todayIso } from "./format";
|
||||||
import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting";
|
import { resolveReportingLines, type OmHolder, type OmUnit } from "./om-reporting";
|
||||||
import { fetchAllRows } from "./supabase/query";
|
|
||||||
import type { Database } from "./supabase/types";
|
|
||||||
|
|
||||||
// Das Organigramm, wie es an einem Stichtag stand oder stehen wird.
|
// Das Organigramm, wie es an einem Stichtag stand oder stehen wird.
|
||||||
//
|
//
|
||||||
@@ -56,55 +54,76 @@ type AssignmentRow = { employee_id: string; position_id: string };
|
|||||||
|
|
||||||
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
|
type PendingRow = { employee_id: string; effective_date: string; payload: Record<string, unknown> };
|
||||||
|
|
||||||
export async function loadOrgAsOf(supabase: SupabaseClient<Database>, asOf: string): Promise<OrgAsOfResult> {
|
export async function loadOrgAsOf(tx: Tx, asOf: string): Promise<OrgAsOfResult> {
|
||||||
const today = todayIso();
|
const today = todayIso();
|
||||||
|
|
||||||
|
// Ohne die 1000-Zeilen-Grenze der API-Schicht fällt das seitenweise
|
||||||
|
// Nachladen weg: sechs Abfragen, jede vollständig.
|
||||||
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
|
const [units, positions, assignments, employees, pending, earliest] = await Promise.all([
|
||||||
fetchAllRows(() => supabase.from("org_units").select("id, parent_id").order("id")),
|
tx.selectFrom("org_units").select(["id", "parent_id"]).orderBy("id").execute(),
|
||||||
fetchAllRows(() =>
|
|
||||||
supabase
|
tx
|
||||||
.from("om_positions")
|
.selectFrom("om_positions as p")
|
||||||
.select("id, position_number, org_unit_id, is_chief, jobs!inner(title)")
|
.innerJoin("jobs as j", "j.id", "p.job_id")
|
||||||
.lte("valid_from", asOf)
|
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "j.title"])
|
||||||
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
|
.where("p.valid_from", "<=", asOf)
|
||||||
.order("id")
|
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
|
||||||
),
|
.orderBy("p.id")
|
||||||
fetchAllRows(() =>
|
.execute(),
|
||||||
supabase
|
|
||||||
.from("position_assignments")
|
tx
|
||||||
.select("employee_id, position_id")
|
.selectFrom("position_assignments")
|
||||||
.lte("valid_from", asOf)
|
.select(["employee_id", "position_id"])
|
||||||
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
|
.where("valid_from", "<=", asOf)
|
||||||
.order("employee_id")
|
.where((eb) => eb.or([eb("valid_to", "is", null), eb("valid_to", ">", asOf)]))
|
||||||
),
|
.orderBy("employee_id")
|
||||||
fetchAllRows(() =>
|
.execute(),
|
||||||
supabase
|
|
||||||
.from("employees")
|
tx
|
||||||
.select("id, personnel_number, first_name, last_name, job_title, karenz_start_date, karenz_return_date, absence_type")
|
.selectFrom("employees")
|
||||||
.order("id")
|
.select([
|
||||||
),
|
"id",
|
||||||
|
"personnel_number",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"job_title",
|
||||||
|
"karenz_start_date",
|
||||||
|
"karenz_return_date",
|
||||||
|
"absence_type",
|
||||||
|
])
|
||||||
|
.orderBy("id")
|
||||||
|
.execute(),
|
||||||
|
|
||||||
asOf > today
|
asOf > today
|
||||||
? fetchAllRows(() =>
|
? tx
|
||||||
supabase
|
.selectFrom("pending_org_changes")
|
||||||
.from("pending_org_changes")
|
.select(["employee_id", "effective_date", "payload"])
|
||||||
.select("employee_id, effective_date, payload")
|
.where("status", "=", "pending")
|
||||||
.eq("status", "pending")
|
.where("effective_date", "<=", asOf)
|
||||||
.lte("effective_date", asOf)
|
.where("change_type", "in", [...PLACEMENT_CHANGES])
|
||||||
.in("change_type", [...PLACEMENT_CHANGES])
|
.orderBy("effective_date")
|
||||||
.order("effective_date")
|
.execute()
|
||||||
)
|
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
supabase.from("position_assignments").select("valid_from").order("valid_from").limit(1).maybeSingle(),
|
|
||||||
|
tx.selectFrom("position_assignments").select("valid_from").orderBy("valid_from").limit(1).executeTakeFirst(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return resolveOrgSnapshot({
|
return resolveOrgSnapshot({
|
||||||
asOf,
|
asOf,
|
||||||
units: units.map((u) => ({ id: u.id, parentId: u.parent_id })),
|
units: units.map((u) => ({ id: u.id, parentId: u.parent_id })),
|
||||||
positions: positions as unknown as PositionRow[],
|
// Der Join liefert den Jobtitel flach; die reine Funktion erwartet ihn
|
||||||
|
// verschachtelt, weil sie so auch aus einem Testbestand gefüttert wird.
|
||||||
|
positions: positions.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
position_number: p.position_number,
|
||||||
|
org_unit_id: p.org_unit_id,
|
||||||
|
is_chief: p.is_chief,
|
||||||
|
jobs: { title: p.title },
|
||||||
|
})),
|
||||||
assignments: assignments as AssignmentRow[],
|
assignments: assignments as AssignmentRow[],
|
||||||
employees: employees as EmployeeRow[],
|
employees: employees as EmployeeRow[],
|
||||||
pending: pending as PendingRow[],
|
pending: pending as PendingRow[],
|
||||||
historyStartsAt: earliest.data?.valid_from ?? null,
|
historyStartsAt: earliest?.valid_from ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import { sql, type Tx } from "./db";
|
||||||
import { fetchAllRows } from "./supabase/query";
|
|
||||||
import type { Database } from "./supabase/types";
|
|
||||||
|
|
||||||
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
|
// Wo jemand in der Organisation steht, steht nicht mehr auf der Person. Es
|
||||||
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
|
// ergibt sich aus der Planstelle, die sie zum Stichtag innehat:
|
||||||
@@ -24,30 +22,25 @@ export type Placement = {
|
|||||||
current: boolean;
|
current: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SELECT =
|
|
||||||
"employee_id, valid_from, valid_to, om_positions!inner(id, position_number, org_unit_id, is_chief, jobs!inner(title))";
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
employee_id: string;
|
employee_id: string;
|
||||||
valid_from: string;
|
valid_from: string;
|
||||||
valid_to: string | null;
|
valid_to: string | null;
|
||||||
om_positions: {
|
position_id: string;
|
||||||
id: string;
|
position_number: string;
|
||||||
position_number: string;
|
org_unit_id: string;
|
||||||
org_unit_id: string;
|
is_chief: boolean;
|
||||||
is_chief: boolean;
|
job_title: string;
|
||||||
jobs: { title: string };
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function toPlacement(row: Row, asOf: string): Placement {
|
function toPlacement(row: Row, asOf: string): Placement {
|
||||||
return {
|
return {
|
||||||
employeeId: row.employee_id,
|
employeeId: row.employee_id,
|
||||||
positionId: row.om_positions.id,
|
positionId: row.position_id,
|
||||||
positionNumber: row.om_positions.position_number,
|
positionNumber: row.position_number,
|
||||||
orgUnitId: row.om_positions.org_unit_id,
|
orgUnitId: row.org_unit_id,
|
||||||
isChief: row.om_positions.is_chief,
|
isChief: row.is_chief,
|
||||||
jobTitle: row.om_positions.jobs.title,
|
jobTitle: row.job_title,
|
||||||
validFrom: row.valid_from,
|
validFrom: row.valid_from,
|
||||||
validTo: row.valid_to,
|
validTo: row.valid_to,
|
||||||
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf),
|
current: row.valid_from <= asOf && (row.valid_to === null || row.valid_to > asOf),
|
||||||
@@ -76,17 +69,33 @@ export function pickPlacements(rows: Row[], asOf: string): Map<string, Placement
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPlacements(
|
export async function loadPlacements(
|
||||||
supabase: SupabaseClient<Database>,
|
tx: Tx,
|
||||||
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
|
{ asOf, employeeIds }: { asOf: string; employeeIds?: string[] }
|
||||||
): Promise<Map<string, Placement>> {
|
): Promise<Map<string, Placement>> {
|
||||||
if (employeeIds?.length === 0) return new Map();
|
if (employeeIds?.length === 0) return new Map();
|
||||||
|
|
||||||
const rows = await fetchAllRows(() => {
|
// Ein Join statt einer eingebetteten Ressource. Und ohne die
|
||||||
const q = supabase.from("position_assignments").select(SELECT).order("employee_id");
|
// 1000-Zeilen-Grenze von PostgREST fällt das seitenweise Nachladen weg,
|
||||||
return employeeIds ? q.in("employee_id", employeeIds) : q;
|
// das es dafür brauchte.
|
||||||
});
|
let q = tx
|
||||||
|
.selectFrom("position_assignments as pa")
|
||||||
|
.innerJoin("om_positions as p", "p.id", "pa.position_id")
|
||||||
|
.innerJoin("jobs as j", "j.id", "p.job_id")
|
||||||
|
.select([
|
||||||
|
"pa.employee_id",
|
||||||
|
"pa.valid_from",
|
||||||
|
"pa.valid_to",
|
||||||
|
"p.id as position_id",
|
||||||
|
"p.position_number",
|
||||||
|
"p.org_unit_id",
|
||||||
|
"p.is_chief",
|
||||||
|
"j.title as job_title",
|
||||||
|
])
|
||||||
|
.orderBy("pa.employee_id");
|
||||||
|
|
||||||
return pickPlacements(rows as unknown as Row[], asOf);
|
if (employeeIds) q = q.where("pa.employee_id", "in", employeeIds);
|
||||||
|
|
||||||
|
return pickPlacements((await q.execute()) as Row[], asOf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
|
// ── Abgeleitete Berichtslinie ──────────────────────────────────────
|
||||||
@@ -105,11 +114,30 @@ export type ReportingLine = {
|
|||||||
acting_manager_id: string | null;
|
acting_manager_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `filter` schränkt die Funktion selbst ein, nicht das Ergebnis im Speicher —
|
||||||
|
* bei der Detailseite wandern damit neun Zeilen über die Leitung statt
|
||||||
|
* achthundert.
|
||||||
|
*/
|
||||||
export async function loadReportingLines(
|
export async function loadReportingLines(
|
||||||
supabase: SupabaseClient<Database>,
|
tx: Tx,
|
||||||
asOf: string
|
asOf: string,
|
||||||
): Promise<Map<string, ReportingLine>> {
|
filter?: { employeeId?: string; actingManagerId?: string }
|
||||||
const { data, error } = await supabase.rpc("om_reporting_lines", { p_as_of: asOf });
|
): Promise<ReportingLine[]> {
|
||||||
if (error) throw new Error(`Berichtslinie konnte nicht geladen werden: ${error.message}`);
|
const conditions = [sql`true`];
|
||||||
return new Map(((data ?? []) as ReportingLine[]).map((l) => [l.employee_id, l]));
|
if (filter?.employeeId) conditions.push(sql`employee_id = ${filter.employeeId}::uuid`);
|
||||||
|
if (filter?.actingManagerId) conditions.push(sql`acting_manager_id = ${filter.actingManagerId}::uuid`);
|
||||||
|
|
||||||
|
const result = await sql<ReportingLine>`
|
||||||
|
select * from om_reporting_lines(${asOf}::date)
|
||||||
|
where ${sql.join(conditions, sql` and `)}
|
||||||
|
`.execute(tx);
|
||||||
|
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie loadReportingLines, aber als Karte über die Personen-Kennung. */
|
||||||
|
export async function loadReportingLineMap(tx: Tx, asOf: string): Promise<Map<string, ReportingLine>> {
|
||||||
|
const lines = await loadReportingLines(tx, asOf);
|
||||||
|
return new Map(lines.map((l) => [l.employee_id, l]));
|
||||||
}
|
}
|
||||||
|
|||||||
123
lib/positions.ts
123
lib/positions.ts
@@ -1,8 +1,6 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { Tx } from "./db";
|
||||||
import { todayIso } from "./format";
|
import { todayIso } from "./format";
|
||||||
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org";
|
import { breadcrumbLabel, loadOrgMaps, type OrgMaps } from "./org";
|
||||||
import { fetchAllRows } from "./supabase/query";
|
|
||||||
import type { Database } from "./supabase/types";
|
|
||||||
|
|
||||||
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
|
// Eine offene Stelle ist keine eigene Sache mehr. Sie ist eine Planstelle
|
||||||
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
|
// ohne laufende Besetzung — Vakanz ist eine Eigenschaft der Planstelle, kein
|
||||||
@@ -23,16 +21,6 @@ export type OpenPositionResolved = {
|
|||||||
vacantSince: string;
|
vacantSince: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PositionRow = {
|
|
||||||
id: string;
|
|
||||||
position_number: string;
|
|
||||||
org_unit_id: string;
|
|
||||||
is_chief: boolean;
|
|
||||||
valid_from: string;
|
|
||||||
jobs: { title: string };
|
|
||||||
position_assignments: { employee_id: string; valid_from: string; valid_to: string | null }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
|
* Wer eine unbesetzte Planstelle führen würde: die Leitung der eigenen
|
||||||
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe
|
* Einheit, für eine Leitungsplanstelle die der übergeordneten — dieselbe
|
||||||
@@ -43,70 +31,89 @@ function managerUnitFor(maps: OrgMaps, orgUnitId: string, isChief: boolean): str
|
|||||||
return maps.units.get(orgUnitId)?.parent_id ?? null;
|
return maps.units.get(orgUnitId)?.parent_id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadOpenPositions(supabase: SupabaseClient<Database>): Promise<OpenPositionResolved[]> {
|
export async function loadOpenPositions(tx: Tx): Promise<OpenPositionResolved[]> {
|
||||||
const asOf = todayIso();
|
const asOf = todayIso();
|
||||||
|
|
||||||
const [orgMaps, positions] = await Promise.all([
|
const [orgMaps, open] = await Promise.all([
|
||||||
loadOrgMaps(supabase),
|
loadOrgMaps(tx),
|
||||||
fetchAllRows(() =>
|
// Unbesetzt heisst: keine am Stichtag laufende Zuordnung. Als NOT EXISTS
|
||||||
supabase
|
// in der Datenbank statt als Filter über alle Planstellen im Speicher.
|
||||||
.from("om_positions")
|
tx
|
||||||
.select(
|
.selectFrom("om_positions as p")
|
||||||
"id, position_number, org_unit_id, is_chief, valid_from, jobs!inner(title), position_assignments(employee_id, valid_from, valid_to)"
|
.innerJoin("jobs as j", "j.id", "p.job_id")
|
||||||
|
.select(["p.id", "p.position_number", "p.org_unit_id", "p.is_chief", "p.valid_from", "j.title"])
|
||||||
|
.where("p.valid_from", "<=", asOf)
|
||||||
|
.where((eb) => eb.or([eb("p.valid_to", "is", null), eb("p.valid_to", ">", asOf)]))
|
||||||
|
.where((eb) =>
|
||||||
|
eb.not(
|
||||||
|
eb.exists(
|
||||||
|
eb
|
||||||
|
.selectFrom("position_assignments as a")
|
||||||
|
.select("a.id")
|
||||||
|
.whereRef("a.position_id", "=", "p.id")
|
||||||
|
.where("a.valid_from", "<=", asOf)
|
||||||
|
.where((e2) => e2.or([e2("a.valid_to", "is", null), e2("a.valid_to", ">", asOf)]))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.lte("valid_from", asOf)
|
)
|
||||||
.or(`valid_to.is.null,valid_to.gt.${asOf}`)
|
.orderBy("p.position_number")
|
||||||
.order("position_number")
|
.execute(),
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const open = (positions as unknown as PositionRow[]).filter(
|
|
||||||
(p) => !p.position_assignments.some((a) => a.valid_from <= asOf && (a.valid_to === null || a.valid_to > asOf))
|
|
||||||
);
|
|
||||||
if (open.length === 0) return [];
|
if (open.length === 0) return [];
|
||||||
|
|
||||||
// Die Leitung der zuständigen Einheit — genau die Planstellen, die als
|
const positionIds = open.map((p) => p.id);
|
||||||
// Leitung markiert und laufend besetzt sind.
|
|
||||||
const chiefUnitIds = Array.from(
|
|
||||||
new Set(open.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief)).filter((id): id is string => Boolean(id)))
|
|
||||||
);
|
|
||||||
const chiefs = chiefUnitIds.length
|
|
||||||
? ((await fetchAllRows(() =>
|
|
||||||
supabase
|
|
||||||
.from("om_positions")
|
|
||||||
.select("org_unit_id, position_assignments!inner(employees!inner(first_name, last_name), valid_to)")
|
|
||||||
.eq("is_chief", true)
|
|
||||||
.in("org_unit_id", chiefUnitIds)
|
|
||||||
.is("position_assignments.valid_to", null)
|
|
||||||
)) as unknown as {
|
|
||||||
org_unit_id: string;
|
|
||||||
position_assignments: { employees: { first_name: string; last_name: string } }[];
|
|
||||||
}[])
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const chiefNameByUnit = new Map(
|
// Zwei Nachschläge: seit wann die Stelle leer steht, und wer sie führen
|
||||||
chiefs.flatMap((c) => {
|
// würde.
|
||||||
const holder = c.position_assignments[0]?.employees;
|
const [ended, chiefs] = await Promise.all([
|
||||||
return holder ? [[c.org_unit_id, `${holder.first_name} ${holder.last_name}`] as const] : [];
|
tx
|
||||||
})
|
.selectFrom("position_assignments")
|
||||||
);
|
.select(["position_id", "valid_to"])
|
||||||
|
.where("position_id", "in", positionIds)
|
||||||
|
.where("valid_to", "is not", null)
|
||||||
|
.execute(),
|
||||||
|
(async () => {
|
||||||
|
const chiefUnitIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
open
|
||||||
|
.map((p) => managerUnitFor(orgMaps, p.org_unit_id, p.is_chief))
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (chiefUnitIds.length === 0) return [];
|
||||||
|
return tx
|
||||||
|
.selectFrom("om_positions as p")
|
||||||
|
.innerJoin("position_assignments as a", "a.position_id", "p.id")
|
||||||
|
.innerJoin("employees as e", "e.id", "a.employee_id")
|
||||||
|
.select(["p.org_unit_id", "e.first_name", "e.last_name"])
|
||||||
|
.where("p.is_chief", "=", true)
|
||||||
|
.where("p.valid_to", "is", null)
|
||||||
|
.where("a.valid_to", "is", null)
|
||||||
|
.where("p.org_unit_id", "in", chiefUnitIds)
|
||||||
|
.execute();
|
||||||
|
})(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const lastEndByPosition = new Map<string, string>();
|
||||||
|
for (const e of ended) {
|
||||||
|
const prev = lastEndByPosition.get(e.position_id);
|
||||||
|
if (e.valid_to && (!prev || e.valid_to > prev)) lastEndByPosition.set(e.position_id, e.valid_to);
|
||||||
|
}
|
||||||
|
const chiefNameByUnit = new Map(chiefs.map((c) => [c.org_unit_id, `${c.first_name} ${c.last_name}`]));
|
||||||
|
|
||||||
return open.map((p) => {
|
return open.map((p) => {
|
||||||
const ended = p.position_assignments
|
|
||||||
.map((a) => a.valid_to)
|
|
||||||
.filter((d): d is string => d !== null)
|
|
||||||
.sort();
|
|
||||||
const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
|
const managerUnit = managerUnitFor(orgMaps, p.org_unit_id, p.is_chief);
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
position_number: p.position_number,
|
position_number: p.position_number,
|
||||||
title: p.jobs.title,
|
title: p.title,
|
||||||
org_unit_id: p.org_unit_id,
|
org_unit_id: p.org_unit_id,
|
||||||
is_chief: p.is_chief,
|
is_chief: p.is_chief,
|
||||||
valid_from: p.valid_from,
|
valid_from: p.valid_from,
|
||||||
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
|
managerName: managerUnit ? (chiefNameByUnit.get(managerUnit) ?? null) : null,
|
||||||
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
|
orgLabel: breadcrumbLabel(orgMaps, p.org_unit_id),
|
||||||
vacantSince: ended.at(-1) ?? p.valid_from,
|
vacantSince: lastEndByPosition.get(p.id) ?? p.valid_from,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
import type { Tx } from "./db";
|
||||||
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
|
import { ancestorsOf, loadOrgMaps, subtreeOf, type OrgMaps } from "./org";
|
||||||
import { loadPlacements } from "./placement";
|
import { loadPlacements } from "./placement";
|
||||||
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
|
import { deriveStatusAsOf, EVENT_DATE_OPEN, parseStatuses, todayIso, type OrgLookups, type ReportEmployee, type ReportEvent } from "./reports";
|
||||||
import { fetchAllRows } from "./supabase/query";
|
import type { EmploymentType, HistoryEventType } from "./supabase/types";
|
||||||
import type { Database, EmploymentType, HistoryEventType } from "./supabase/types";
|
|
||||||
|
|
||||||
// Shared by the Berichte page and /api/export/* so they can never drift on
|
// Shared by the Berichte page and /api/export/* so they can never drift on
|
||||||
// what "the current view" means — same filters, same stichtag/event-window
|
// what "the current view" means — same filters, same stichtag/event-window
|
||||||
@@ -40,13 +39,13 @@ export function lookupsFromOrgMaps(orgMaps: OrgMaps, locations: { id: string; na
|
|||||||
return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) };
|
return { divisionName, departmentName, teamName, locationName: new Map(locations.map((l) => [l.id, l.name])) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promise<{
|
export async function loadOrgLookups(tx: Tx): Promise<{
|
||||||
lookups: OrgLookups;
|
lookups: OrgLookups;
|
||||||
orgMaps: OrgMaps;
|
orgMaps: OrgMaps;
|
||||||
divisions: { id: string; name: string }[];
|
divisions: { id: string; name: string }[];
|
||||||
locations: { id: string; name: string }[];
|
locations: { id: string; name: string }[];
|
||||||
}> {
|
}> {
|
||||||
const orgMaps = await loadOrgMaps(supabase);
|
const orgMaps = await loadOrgMaps(tx);
|
||||||
const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name }));
|
const locations = orgMaps.locationList.map((l) => ({ id: l.id, name: l.name }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -60,37 +59,61 @@ export async function loadOrgLookups(supabase: SupabaseClient<Database>): Promis
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SNAPSHOT_EMPLOYEE_COLUMNS =
|
const SNAPSHOT_EMPLOYEE_COLUMNS = [
|
||||||
"id, first_name, last_name, job_title, location_id, employment_type, contract_type, entry_date, exit_date, weekly_hours, source, paygrade, birth_date, gender, karenz_start_date, karenz_return_date, worker_type, collective_agreement, work_days, is_betriebsrat, has_dienstwagen, is_laterale_fuehrung, is_c_level";
|
"id",
|
||||||
|
"first_name",
|
||||||
|
"last_name",
|
||||||
|
"job_title",
|
||||||
|
"location_id",
|
||||||
|
"employment_type",
|
||||||
|
"contract_type",
|
||||||
|
"entry_date",
|
||||||
|
"exit_date",
|
||||||
|
"weekly_hours",
|
||||||
|
"source",
|
||||||
|
"paygrade",
|
||||||
|
"birth_date",
|
||||||
|
"gender",
|
||||||
|
"karenz_start_date",
|
||||||
|
"karenz_return_date",
|
||||||
|
"worker_type",
|
||||||
|
"collective_agreement",
|
||||||
|
"work_days",
|
||||||
|
"is_betriebsrat",
|
||||||
|
"has_dienstwagen",
|
||||||
|
"is_laterale_fuehrung",
|
||||||
|
"is_c_level",
|
||||||
|
] as const;
|
||||||
|
|
||||||
// employee_id -> number of employee_dependents rows. Selects only the FK
|
// Anzahl der Angehörigen je Person. Nur der Fremdschlüssel wird gelesen —
|
||||||
// column (no dependent PII needed) since only per-employee counts feed the
|
// für die Berichtsdimensionen zählt die Anzahl, nicht wer es ist.
|
||||||
// has_dependents/avg_dependents report dimensions; counted client-side
|
export async function loadDependentsCounts(tx: Tx): Promise<Map<string, number>> {
|
||||||
// since the Supabase JS client has no `count(*) group by employee_id`
|
// Am direkten Zugang zählt die Datenbank, statt dass die Anwendung alle
|
||||||
// shorthand. Shared by the Bestand pivot and the full employees export.
|
// Zeilen holt und sie selbst durchgeht.
|
||||||
export async function loadDependentsCounts(supabase: SupabaseClient<Database>): Promise<Map<string, number>> {
|
const rows = await tx
|
||||||
const rows = await fetchAllRows(() => supabase.from("employee_dependents").select("employee_id").order("employee_id"));
|
.selectFrom("employee_dependents")
|
||||||
const counts = new Map<string, number>();
|
.select(({ fn }) => ["employee_id", fn.countAll<string>().as("anzahl")])
|
||||||
for (const d of rows) counts.set(d.employee_id, (counts.get(d.employee_id) ?? 0) + 1);
|
.groupBy("employee_id")
|
||||||
return counts;
|
.execute();
|
||||||
|
return new Map(rows.map((r) => [r.employee_id, Number(r.anzahl)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst.
|
// Bestand zum Stichtag: Status *und* Einordnung werden auf `asOf` aufgelöst.
|
||||||
export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>, filters: SnapshotFilters): Promise<ReportEmployee[]> {
|
export async function loadSnapshotEmployees(tx: Tx, filters: SnapshotFilters): Promise<ReportEmployee[]> {
|
||||||
const asOf = filters.asOf || todayIso();
|
const asOf = filters.asOf || todayIso();
|
||||||
|
|
||||||
function snapshotQuery() {
|
function snapshotQuery() {
|
||||||
let query = supabase.from("employees").select(SNAPSHOT_EMPLOYEE_COLUMNS).order("id");
|
let q = tx.selectFrom("employees").select([...SNAPSHOT_EMPLOYEE_COLUMNS]).orderBy("id");
|
||||||
if (filters.location) query = query.eq("location_id", filters.location);
|
if (filters.location) q = q.where("location_id", "=", filters.location);
|
||||||
if (filters.employment) query = query.eq("employment_type", filters.employment as EmploymentType);
|
if (filters.employment) q = q.where("employment_type", "=", filters.employment as EmploymentType);
|
||||||
return query;
|
return q;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
|
const [data, dependentsCounts, placements, orgMaps] = await Promise.all([
|
||||||
fetchAllRows(snapshotQuery),
|
snapshotQuery().execute(),
|
||||||
loadDependentsCounts(supabase),
|
loadDependentsCounts(tx),
|
||||||
loadPlacements(supabase, { asOf }),
|
loadPlacements(tx, { asOf }),
|
||||||
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null),
|
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst
|
// Der Bereichsfilter meint den ganzen Teilbaum: „Produktion" schliesst
|
||||||
@@ -146,40 +169,38 @@ export async function loadSnapshotEmployees(supabase: SupabaseClient<Database>,
|
|||||||
// from/to: "" (unset) falls back to the current calendar year; the literal
|
// from/to: "" (unset) falls back to the current calendar year; the literal
|
||||||
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
|
// sentinel EVENT_DATE_OPEN means that side of the interval is intentionally
|
||||||
// unbounded (e.g. "alle Ereignisse bis heute", no start date).
|
// unbounded (e.g. "alle Ereignisse bis heute", no start date).
|
||||||
export async function loadEventHistory(supabase: SupabaseClient<Database>, filters: EventFilters): Promise<ReportEvent[]> {
|
export async function loadEventHistory(tx: Tx, filters: EventFilters): Promise<ReportEvent[]> {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`;
|
const from = filters.from === EVENT_DATE_OPEN ? undefined : filters.from || `${currentYear}-01-01`;
|
||||||
const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
|
const to = filters.to === EVENT_DATE_OPEN ? undefined : filters.to || `${currentYear}-12-31`;
|
||||||
|
|
||||||
function historyQuery() {
|
function historyQuery() {
|
||||||
let query = supabase.from("employee_history").select("employee_id, event_date, event_type, description").order("id");
|
let q = tx
|
||||||
if (from) query = query.gte("event_date", from);
|
.selectFrom("employee_history")
|
||||||
if (to) query = query.lte("event_date", to);
|
.select(["employee_id", "event_date", "event_type", "description"])
|
||||||
if (filters.eventType) query = query.eq("event_type", filters.eventType);
|
.orderBy("id");
|
||||||
return query;
|
if (from) q = q.where("event_date", ">=", from);
|
||||||
|
if (to) q = q.where("event_date", "<=", to);
|
||||||
|
if (filters.eventType) q = q.where("event_type", "=", filters.eventType);
|
||||||
|
return q;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [history, employees, assignments, orgMaps] = await Promise.all([
|
const [history, employees, assignments, orgMaps] = await Promise.all([
|
||||||
fetchAllRows(historyQuery),
|
historyQuery().execute(),
|
||||||
fetchAllRows(() => supabase.from("employees").select("id, first_name, last_name, job_title, location_id").order("id")),
|
tx.selectFrom("employees").select(["id", "first_name", "last_name", "job_title", "location_id"]).orderBy("id").execute(),
|
||||||
fetchAllRows(() =>
|
tx
|
||||||
supabase
|
.selectFrom("position_assignments as a")
|
||||||
.from("position_assignments")
|
.innerJoin("om_positions as p", "p.id", "a.position_id")
|
||||||
.select("employee_id, valid_from, valid_to, om_positions!inner(org_unit_id)")
|
.select(["a.employee_id", "a.valid_from", "a.valid_to", "p.org_unit_id"])
|
||||||
.order("employee_id")
|
.orderBy("a.employee_id")
|
||||||
),
|
.execute(),
|
||||||
filters.division ? loadOrgMaps(supabase) : Promise.resolve(null),
|
filters.division ? loadOrgMaps(tx) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
|
const spans = new Map<string, { from: string; to: string | null; unitId: string }[]>();
|
||||||
for (const a of assignments as unknown as {
|
for (const a of assignments) {
|
||||||
employee_id: string;
|
|
||||||
valid_from: string;
|
|
||||||
valid_to: string | null;
|
|
||||||
om_positions: { org_unit_id: string };
|
|
||||||
}[]) {
|
|
||||||
const list = spans.get(a.employee_id) ?? [];
|
const list = spans.get(a.employee_id) ?? [];
|
||||||
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.om_positions.org_unit_id });
|
list.push({ from: a.valid_from, to: a.valid_to, unitId: a.org_unit_id });
|
||||||
spans.set(a.employee_id, list);
|
spans.set(a.employee_id, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +214,7 @@ export async function loadEventHistory(supabase: SupabaseClient<Database>, filte
|
|||||||
if (filters.location && emp.location_id !== filters.location) continue;
|
if (filters.location && emp.location_id !== filters.location) continue;
|
||||||
|
|
||||||
const unitId =
|
const unitId =
|
||||||
spans.get(h.employee_id)?.find((s) => s.from <= h.event_date && (s.to === null || s.to > h.event_date))?.unitId ?? null;
|
spans.get(h.employee_id)?.find((s2) => s2.from <= h.event_date && (s2.to === null || s2.to > h.event_date))?.unitId ?? null;
|
||||||
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
|
if (allowedUnits && (!unitId || !allowedUnits.has(unitId))) continue;
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import "server-only";
|
|
||||||
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
|
||||||
import type { Database } from "./types";
|
|
||||||
|
|
||||||
// Service-role client: bypasses RLS entirely. Server-only — never import this
|
|
||||||
// from a Client Component or anything bundled for the browser. The
|
|
||||||
// "server-only" import makes an accidental client-side import a build error
|
|
||||||
// instead of a runtime one.
|
|
||||||
export function createAdminClient() {
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
||||||
|
|
||||||
if (!supabaseUrl) {
|
|
||||||
throw new Error("Missing NEXT_PUBLIC_SUPABASE_URL");
|
|
||||||
}
|
|
||||||
if (!serviceRoleKey) {
|
|
||||||
throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY");
|
|
||||||
}
|
|
||||||
|
|
||||||
return createSupabaseClient<Database>(supabaseUrl, serviceRoleKey, {
|
|
||||||
auth: { autoRefreshToken: false, persistSession: false },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
import type { Database } from "./types";
|
|
||||||
|
|
||||||
// Route Handlers under /api/export/* are outside the App Router layout tree,
|
|
||||||
// so app/(app)/layout.tsx's HR gate never runs for them — each one has to
|
|
||||||
// re-establish that the caller is an active HR user itself. RLS is still the
|
|
||||||
// real boundary (an unauthorized session simply reads nothing); this exists
|
|
||||||
// so those routes answer 401/403 instead of handing back an empty workbook.
|
|
||||||
export async function requireHrUser(supabase: SupabaseClient<Database>): Promise<NextResponse | null> {
|
|
||||||
const {
|
|
||||||
data: { user },
|
|
||||||
} = await supabase.auth.getUser();
|
|
||||||
if (!user) return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
|
||||||
|
|
||||||
const { data: profile } = await supabase.from("profiles").select("role, is_active").eq("id", user.id).maybeSingle();
|
|
||||||
if (profile?.role !== "hr" || profile.is_active !== true) {
|
|
||||||
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// PostgREST's .or() filter syntax treats "," "(" and ")" as structural
|
|
||||||
// delimiters between conditions. A raw user-supplied search term containing
|
|
||||||
// them (e.g. from a search box or ?q= param) can break out of the intended
|
|
||||||
// column conditions and append arbitrary extra filters to the query. Strip
|
|
||||||
// them before interpolating — harmless for real name/title searches, which
|
|
||||||
// never legitimately contain them.
|
|
||||||
export function sanitizeIlikeTerm(term: string): string {
|
|
||||||
return term.replace(/[,()]/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgREST caps every response at db.max_rows (1000, see
|
|
||||||
// supabase/config.toml) and does so *silently* — a query over ~800 employees
|
|
||||||
// or the employee_history log just stops returning rows, and a report or
|
|
||||||
// export built from it is quietly wrong rather than failing. Anything that
|
|
||||||
// aggregates a whole table has to page explicitly; anything that renders a
|
|
||||||
// bounded list (an employee page, the audit log) uses .range() directly and
|
|
||||||
// does not need this.
|
|
||||||
const PAGE_SIZE = 1000;
|
|
||||||
|
|
||||||
type PagedQuery<Row> = {
|
|
||||||
range: (from: number, to: number) => PromiseLike<{ data: Row[] | null; error: unknown }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchAllRows<Row>(buildQuery: () => PagedQuery<Row>): Promise<Row[]> {
|
|
||||||
const rows: Row[] = [];
|
|
||||||
for (let page = 0; ; page++) {
|
|
||||||
const { data, error } = await buildQuery().range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1);
|
|
||||||
if (error || !data) break;
|
|
||||||
rows.push(...data);
|
|
||||||
if (data.length < PAGE_SIZE) break;
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { Kysely, PostgresDialect } from "kysely";
|
||||||
import { applyDerivedStatusFilter } from "@/lib/employee-status-filter";
|
import { Pool } from "pg";
|
||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import { derivedStatusFilter } from "@/lib/employee-status-filter";
|
||||||
|
import type { Schema } from "@/lib/db/schema";
|
||||||
import { todayIso } from "@/lib/format";
|
import { todayIso } from "@/lib/format";
|
||||||
import { deriveStatusAsOf } from "@/lib/reports";
|
import { deriveStatusAsOf } from "@/lib/reports";
|
||||||
import type { EmploymentStatus } from "@/lib/supabase/types";
|
import type { EmploymentStatus } from "@/lib/supabase/types";
|
||||||
import { adminClient } from "./helpers";
|
|
||||||
|
|
||||||
// lib/employee-status-filter.ts is a SQL restatement of deriveStatusAsOf():
|
// lib/employee-status-filter.ts is a SQL restatement of deriveStatusAsOf():
|
||||||
// the employee list pages in the database and cannot derive status in JS, so
|
// the employee list pages in the database and cannot derive status in JS, so
|
||||||
@@ -13,22 +15,36 @@ import { adminClient } from "./helpers";
|
|||||||
//
|
//
|
||||||
// Only a real database can settle it, so this runs both over the whole
|
// Only a real database can settle it, so this runs both over the whole
|
||||||
// seeded roster and demands the same set of ids.
|
// seeded roster and demands the same set of ids.
|
||||||
describe("derived status filter matches deriveStatusAsOf", () => {
|
//
|
||||||
|
// Eigene Verbindung statt der Zugriffsschicht: geprüft wird die Bedingung,
|
||||||
|
// nicht die Berechtigung. Mit RLS dazwischen liefe der Test gegen eine
|
||||||
|
// gefilterte Teilmenge und bewiese nichts über die Regel.
|
||||||
|
const db = new Kysely<Schema>({
|
||||||
|
dialect: new PostgresDialect({ pool: new Pool({ connectionString: process.env.DATABASE_URL, max: 2 }) }),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.DATABASE_URL)("derived status filter matches deriveStatusAsOf", () => {
|
||||||
const asOf = todayIso();
|
const asOf = todayIso();
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await db.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
async function idsFromDatabase(statuses: EmploymentStatus[]): Promise<Set<string>> {
|
async function idsFromDatabase(statuses: EmploymentStatus[]): Promise<Set<string>> {
|
||||||
const query = adminClient.from("employees").select("id");
|
const rows = await db
|
||||||
const { data, error } = await applyDerivedStatusFilter(query, statuses, asOf);
|
.selectFrom("employees")
|
||||||
if (error) throw new Error(error.message);
|
.select("id")
|
||||||
return new Set((data ?? []).map((r) => r.id));
|
.where((eb) => derivedStatusFilter(eb, statuses, asOf) ?? eb.val(true))
|
||||||
|
.execute();
|
||||||
|
return new Set(rows.map((r) => r.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function idsFromDerivation(statuses: EmploymentStatus[]): Promise<Set<string>> {
|
async function idsFromDerivation(statuses: EmploymentStatus[]): Promise<Set<string>> {
|
||||||
const { data, error } = await adminClient
|
const rows = await db
|
||||||
.from("employees")
|
.selectFrom("employees")
|
||||||
.select("id, entry_date, exit_date, karenz_start_date, karenz_return_date");
|
.select(["id", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
|
||||||
if (error) throw new Error(error.message);
|
.execute();
|
||||||
return new Set((data ?? []).filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id));
|
return new Set(rows.filter((e) => statuses.includes(deriveStatusAsOf(e, asOf))).map((e) => e.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectSameSet(statuses: EmploymentStatus[]) {
|
async function expectSameSet(statuses: EmploymentStatus[]) {
|
||||||
@@ -68,12 +84,11 @@ describe("derived status filter matches deriveStatusAsOf", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns everyone when no status is selected", async () => {
|
it("returns everyone when no status is selected", async () => {
|
||||||
const { count: total } = await adminClient.from("employees").select("id", { count: "exact", head: true });
|
const total = await db
|
||||||
const { count: filtered } = await applyDerivedStatusFilter(
|
.selectFrom("employees")
|
||||||
adminClient.from("employees").select("id", { count: "exact", head: true }),
|
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
||||||
[],
|
.executeTakeFirstOrThrow();
|
||||||
asOf
|
const filtered = await idsFromDatabase([]);
|
||||||
);
|
expect(filtered.size).toBe(Number(total.anzahl));
|
||||||
expect(filtered).toBe(total);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,14 +9,18 @@ import {
|
|||||||
parseMeasure,
|
parseMeasure,
|
||||||
parseSplitDimension,
|
parseSplitDimension,
|
||||||
} from "@/lib/reports";
|
} from "@/lib/reports";
|
||||||
import { sanitizeIlikeTerm } from "@/lib/supabase/query";
|
|
||||||
|
|
||||||
// The route under test imports lib/supabase/admin.ts, which is guarded by
|
// Die Cron-Route lädt lib/db, das über `import "server-only"` abgesichert
|
||||||
// `import "server-only"` — that throws when loaded outside Next's own
|
// ist und beim Laden eine DATABASE_URL verlangt — beides gibt es unter
|
||||||
// server compilation (e.g. here, under plain Vitest/Node). Mock it out:
|
// reinem Vitest nicht. Weggemockt: geprüft wird hier nur der
|
||||||
// these tests only exercise the auth guard, which returns before the real
|
// Geheimnis-Abgleich, der zurückkehrt, bevor irgendeine Verbindung
|
||||||
// admin client is ever created.
|
// entsteht.
|
||||||
vi.mock("@/lib/supabase/admin", () => ({ createAdminClient: vi.fn() }));
|
// `import "server-only"` wirft ausserhalb der Server-Übersetzung von Next —
|
||||||
|
// also auch hier. Der Riegel ist im Betrieb richtig; für den Test wird das
|
||||||
|
// Modul zu einer leeren Hülle.
|
||||||
|
vi.mock("server-only", () => ({}));
|
||||||
|
vi.mock("@/lib/db", () => ({ asSystem: vi.fn(), withUser: vi.fn() }));
|
||||||
|
vi.mock("@/lib/db/rpc", () => ({ callFunction: vi.fn() }));
|
||||||
|
|
||||||
describe("sanitizeForSpreadsheetCell", () => {
|
describe("sanitizeForSpreadsheetCell", () => {
|
||||||
it("prefixes values that would be read as a formula by Excel/Sheets", () => {
|
it("prefixes values that would be read as a formula by Excel/Sheets", () => {
|
||||||
@@ -37,20 +41,11 @@ describe("sanitizeForSpreadsheetCell", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sanitizeIlikeTerm", () => {
|
// Der Test zu sanitizeIlikeTerm ist entfallen, weil die Funktion es ist.
|
||||||
it("strips PostgREST or-filter delimiter characters", () => {
|
// Sie entschärfte Zeichen, die in der Filtersyntax der alten API-Schicht
|
||||||
expect(sanitizeIlikeTerm("a,b")).toBe("ab");
|
// strukturelle Bedeutung hatten. Am direkten Datenbankzugang wird der
|
||||||
expect(sanitizeIlikeTerm("a(b)c")).toBe("abc");
|
// Suchbegriff als Parameter gebunden — ein Komma oder eine Klammer darin
|
||||||
// An attempt to close the current ilike condition and append another
|
// ist schlicht ein Zeichen. Die Lücke ist nicht abgesichert, sondern weg.
|
||||||
// column filter is neutralized by removing the delimiters, not escaped
|
|
||||||
// into a differently-structured (but still injected) query.
|
|
||||||
expect(sanitizeIlikeTerm("x),sv_nummer.ilike.%")).toBe("xsv_nummer.ilike.%");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves a normal search term untouched", () => {
|
|
||||||
expect(sanitizeIlikeTerm("Gruber")).toBe("Gruber");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("report query-string parsing", () => {
|
describe("report query-string parsing", () => {
|
||||||
it("falls back to a known dimension instead of passing an unknown one through", () => {
|
it("falls back to a known dimension instead of passing an unknown one through", () => {
|
||||||
@@ -152,6 +147,6 @@ describe("protected export route without a session (/api/export/employees)", ()
|
|||||||
const request = new NextRequest("http://localhost/api/export/employees");
|
const request = new NextRequest("http://localhost/api/export/employees");
|
||||||
const res = await GET(request);
|
const res = await GET(request);
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
vi.doUnmock("@/lib/supabase/server");
|
vi.doUnmock("@/lib/auth/session");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user