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.
357 lines
14 KiB
TypeScript
357 lines
14 KiB
TypeScript
import { ChevronRight } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { DraftsCard } from "@/components/dashboard/DraftsCard";
|
|
import { Card, CARD_CLASS, CardTitle } from "@/components/ui/Card";
|
|
import { actionBadgeStyle } from "@/lib/colors";
|
|
import { addDaysIso, fmtDate, todayIso } from "@/lib/format";
|
|
import { divisionOf, loadOrgMaps } from "@/lib/org";
|
|
import { loadPlacements } from "@/lib/placement";
|
|
import { loadOpenPositions } from "@/lib/positions";
|
|
import { deriveStatusAsOf } from "@/lib/reports";
|
|
import { currentUserId } from "@/lib/auth/session";
|
|
import { withUser } from "@/lib/db";
|
|
import type { HistoryEventType } from "@/lib/supabase/types";
|
|
|
|
// Each KPI carries a colour already; the accent bar repeats it in a second
|
|
// channel so the tiles are scannable as a row rather than six identical
|
|
// boxes, and so the meaning does not rest on hue alone.
|
|
const TONE: Record<string, { text: string; bar: string }> = {
|
|
default: { text: "text-ink", bar: "bg-ink-muted" },
|
|
success: { text: "text-success-text", bar: "bg-success-text" },
|
|
danger: { text: "text-danger-text", bar: "bg-danger-text" },
|
|
warning: { text: "text-warning-text", bar: "bg-warning-text" },
|
|
brand: { text: "text-brand-700", bar: "bg-brand-500" },
|
|
};
|
|
|
|
const DOT_STYLES: Record<string, string> = {
|
|
Eintritt: "bg-success-text",
|
|
Wiedereintritt: "bg-success-text",
|
|
Rückkehr: "bg-success-text",
|
|
Austritt: "bg-danger-text",
|
|
Versetzung: "bg-info-text",
|
|
Beförderung: "bg-purple-text",
|
|
Reorganisation: "bg-purple-text",
|
|
Karenz: "bg-warning-text",
|
|
Vertragsänderung: "bg-warning-text",
|
|
Stammdatenänderung: "bg-warning-text",
|
|
Gehaltsanpassung: "bg-warning-text",
|
|
};
|
|
|
|
const KIND_LABEL = { hire: "Eintritt", exit: "Austritt", return: "Rückkehr aus Abwesenheit" } as const;
|
|
|
|
export default async function DashboardPage() {
|
|
// Built as strings, not by round-tripping a local Date through
|
|
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
|
|
// the previous year in UTC, which shifted the whole YTD window a day early
|
|
// and dropped 31 December from it entirely.
|
|
const today = todayIso();
|
|
const year = today.slice(0, 4);
|
|
const yearStart = `${year}-01-01`;
|
|
const yearEnd = `${year}-12-31`;
|
|
const in60Iso = addDaysIso(today, 60);
|
|
|
|
const userId = await currentUserId();
|
|
|
|
// Headcount, FTE, Karenz and the division bars all come from one full read
|
|
// and the *derived* status, not from the `employees.status` column.
|
|
//
|
|
// That column only ever reflects what the last mutation or cron run wrote,
|
|
// while every report derives status from entry/exit/karenz dates — so a
|
|
// planned hire whose start date has passed, or a Karenz that ended without
|
|
// anyone recording the return, made the dashboard and the Berichte page
|
|
// disagree about the same headcount. Same derivation, same numbers.
|
|
const {
|
|
drafts,
|
|
staffRows,
|
|
hiresYtd,
|
|
exitsYtd,
|
|
openPositions,
|
|
orgMaps,
|
|
placements,
|
|
upcomingHires,
|
|
upcomingExits,
|
|
upcomingReturns,
|
|
history,
|
|
} = await withUser(userId, async (tx) => {
|
|
const countIn = (types: readonly HistoryEventType[]) =>
|
|
tx
|
|
.selectFrom("employee_history")
|
|
.select(({ fn }) => fn.countAll<string>().as("anzahl"))
|
|
.where("event_type", "in", [...types])
|
|
.where("event_date", ">=", yearStart)
|
|
.where("event_date", "<=", yearEnd)
|
|
.executeTakeFirst();
|
|
|
|
const [
|
|
drafts,
|
|
staffRows,
|
|
hiresYtd,
|
|
exitsYtd,
|
|
openPositions,
|
|
orgMaps,
|
|
placements,
|
|
upcomingHires,
|
|
upcomingExits,
|
|
upcomingReturns,
|
|
history,
|
|
] = await Promise.all([
|
|
userId
|
|
? tx
|
|
.selectFrom("hire_drafts")
|
|
.select(["id", "step", "payload", "updated_at"])
|
|
.where("created_by", "=", userId)
|
|
.orderBy("updated_at", "desc")
|
|
.execute()
|
|
: Promise.resolve([]),
|
|
|
|
tx
|
|
.selectFrom("employees")
|
|
.select(["id", "weekly_hours", "entry_date", "exit_date", "karenz_start_date", "karenz_return_date"])
|
|
.orderBy("id")
|
|
.execute(),
|
|
|
|
// Entries/exits count history events, which is what the linked report
|
|
// counts too. `entry_date` would also sweep up rehires, whose event is
|
|
// logged as 'Wiedereintritt' — the tile and its destination then showed
|
|
// different numbers for the same year.
|
|
countIn(["Eintritt", "Wiedereintritt"]),
|
|
countIn(["Austritt"]),
|
|
|
|
loadOpenPositions(tx),
|
|
loadOrgMaps(tx),
|
|
loadPlacements(tx, { asOf: today }),
|
|
|
|
tx
|
|
.selectFrom("employees")
|
|
.select(["id", "first_name", "last_name", "entry_date"])
|
|
.where("status", "=", "Geplant")
|
|
.where("entry_date", ">=", today)
|
|
.where("entry_date", "<=", in60Iso)
|
|
.execute(),
|
|
|
|
tx
|
|
.selectFrom("employees")
|
|
.select(["id", "first_name", "last_name", "exit_date"])
|
|
.where("exit_date", "is not", null)
|
|
.where("exit_date", ">=", today)
|
|
.where("exit_date", "<=", in60Iso)
|
|
.execute(),
|
|
|
|
tx
|
|
.selectFrom("employees")
|
|
.select(["id", "first_name", "last_name", "karenz_return_date"])
|
|
.where("status", "=", "Karenz")
|
|
.where("karenz_return_date", "is not", null)
|
|
.where("karenz_return_date", ">=", today)
|
|
.where("karenz_return_date", "<=", in60Iso)
|
|
.execute(),
|
|
|
|
tx
|
|
.selectFrom("employee_history as h")
|
|
.leftJoin("employees as e", "e.id", "h.employee_id")
|
|
.select(["h.id", "h.employee_id", "h.event_date", "h.event_type", "h.description", "e.first_name", "e.last_name"])
|
|
.orderBy("h.event_date", "desc")
|
|
.orderBy("h.created_at", "desc")
|
|
.limit(10)
|
|
.execute(),
|
|
]);
|
|
|
|
return {
|
|
drafts,
|
|
staffRows,
|
|
hiresYtd: Number(hiresYtd?.anzahl ?? 0),
|
|
exitsYtd: Number(exitsYtd?.anzahl ?? 0),
|
|
openPositions,
|
|
orgMaps,
|
|
placements,
|
|
upcomingHires,
|
|
upcomingExits,
|
|
upcomingReturns,
|
|
history,
|
|
};
|
|
});
|
|
|
|
// "Aktiv" means status Aktiv — somebody on Karenz is employed but not
|
|
// active, and is counted by its own tile instead. FTE follows the same
|
|
// set: Karenz contributes no capacity, so including it would overstate
|
|
// what the company can actually staff.
|
|
//
|
|
// Note this is narrower than DEFAULT_STATUSES in lib/reports (Aktiv +
|
|
// Karenz), which still governs what the Berichte page shows when no
|
|
// status filter is chosen.
|
|
const statusOf = (row: (typeof staffRows)[number]) => deriveStatusAsOf(row, today);
|
|
const activeStaff = staffRows.filter((row) => statusOf(row) === "Aktiv");
|
|
const activeCount = activeStaff.length;
|
|
const karenzCount = staffRows.filter((row) => statusOf(row) === "Karenz").length;
|
|
const fte = activeStaff.reduce((sum, row) => sum + Number(row.weekly_hours), 0) / 38.5;
|
|
|
|
// Der Bereich einer Person steht nicht mehr auf ihr; er ergibt sich aus der
|
|
// Einheit ihrer Planstelle und deren Vorfahren. Die Bereichsleitung selbst
|
|
// sitzt *am* Bereich, ihre Leute darunter — beide landen über die
|
|
// Vorfahrenkette im selben Balken.
|
|
const headcountByDivision = new Map<string, number>();
|
|
for (const row of activeStaff) {
|
|
const division = divisionOf(orgMaps, placements.get(row.id)?.orgUnitId);
|
|
if (!division) continue;
|
|
headcountByDivision.set(division.id, (headcountByDivision.get(division.id) ?? 0) + 1);
|
|
}
|
|
const divisionBars = orgMaps.unitList
|
|
.filter((u) => u.unit_type === "Bereich")
|
|
.map((d) => ({ name: d.name, count: headcountByDivision.get(d.id) ?? 0 }))
|
|
.sort((a, b) => b.count - a.count);
|
|
const maxDivisionCount = Math.max(1, ...divisionBars.map((d) => d.count));
|
|
|
|
type UpcomingItem = { id: string; label: string; date: string; kind: keyof typeof KIND_LABEL };
|
|
const upcoming: UpcomingItem[] = [
|
|
...(upcomingHires).map((e) => ({
|
|
id: e.id,
|
|
label: `${e.first_name} ${e.last_name}`,
|
|
date: e.entry_date,
|
|
kind: "hire" as const,
|
|
})),
|
|
...(upcomingExits).map((e) => ({
|
|
id: e.id,
|
|
label: `${e.first_name} ${e.last_name}`,
|
|
date: e.exit_date!,
|
|
kind: "exit" as const,
|
|
})),
|
|
...(upcomingReturns).map((e) => ({
|
|
id: e.id,
|
|
label: `${e.first_name} ${e.last_name}`,
|
|
date: e.karenz_return_date!,
|
|
kind: "return" as const,
|
|
})),
|
|
]
|
|
.sort((a, b) => a.date.localeCompare(b.date))
|
|
.slice(0, 8);
|
|
|
|
// Each tile links to the view that shows what it counts, with the filters
|
|
// pre-applied.
|
|
//
|
|
// Two of them cannot match exactly, and it is worth knowing which: the
|
|
// headcount tiles filter `employees` and their targets filter the same
|
|
// table, so those agree. Eintritte/Austritte count `employees.entry_date`
|
|
// / `exit_date`, while the events report counts `employee_history` rows —
|
|
// and rehire_employee sets entry_date but logs the event as
|
|
// 'Wiedereintritt'. A year with rehires therefore shows a slightly higher
|
|
// number on the tile than in the linked report.
|
|
const kpis = [
|
|
{
|
|
label: "Aktive Mitarbeiter:innen",
|
|
value: activeCount,
|
|
tone: "default",
|
|
href: "/employees?status=Aktiv",
|
|
},
|
|
{ label: "FTE", value: fte.toFixed(1), tone: "default", href: "/reports?mode=snapshot&measure=fte&status=Aktiv" },
|
|
{
|
|
label: "Eintritte (Jahr)",
|
|
value: hiresYtd,
|
|
tone: "success",
|
|
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
|
|
},
|
|
{
|
|
label: "Austritte (Jahr)",
|
|
value: exitsYtd,
|
|
tone: "danger",
|
|
href: `/reports?mode=events&eventType=Austritt&from=${yearStart}&to=${yearEnd}`,
|
|
},
|
|
{ label: "Langzeitabwesend", value: karenzCount, tone: "warning", href: "/employees?status=Karenz" },
|
|
{ label: "Offene Positionen", value: openPositions.length, tone: "brand", href: "/positions" },
|
|
];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
{drafts && drafts.length > 0 && <DraftsCard drafts={drafts} />}
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
|
{kpis.map((kpi) => (
|
|
<Link
|
|
key={kpi.label}
|
|
href={kpi.href}
|
|
className={`${CARD_CLASS} group relative overflow-hidden p-4 pl-5 transition-shadow hover:shadow-[var(--shadow-card-hover)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500`}
|
|
>
|
|
<span className={`absolute inset-y-0 left-0 w-1 ${TONE[kpi.tone].bar}`} aria-hidden />
|
|
{/* Number first in the visual order: it is what the eye is
|
|
looking for, and the label only qualifies it. */}
|
|
<div className={`text-3xl font-extrabold leading-none tabular-nums ${TONE[kpi.tone].text}`}>{kpi.value}</div>
|
|
<div className="mt-1.5 flex items-center gap-1 text-xs font-semibold leading-tight text-ink-muted">
|
|
{kpi.label}
|
|
<ChevronRight className="h-3 w-3 shrink-0 opacity-0 transition-opacity group-hover:opacity-100" aria-hidden />
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
<Card>
|
|
{/* Aktive, not headcount: the bars count the same set as the tile
|
|
above them, which excludes Karenz. */}
|
|
<CardTitle className="mb-3">Aktive nach Bereich</CardTitle>
|
|
<div className="flex flex-col gap-2.5">
|
|
{divisionBars.map((d) => (
|
|
<div key={d.name}>
|
|
<div className="mb-1 flex justify-between text-xs">
|
|
<span className="text-ink-body">{d.name}</span>
|
|
<span className="font-semibold tabular-nums text-ink">{d.count}</span>
|
|
</div>
|
|
{/* Rounded ends and a minimum width so the smallest division
|
|
still reads as a bar rather than a stray pixel. */}
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-surface">
|
|
<div
|
|
className="h-full rounded-full bg-brand-500"
|
|
style={{ width: `${Math.max(2, (d.count / maxDivisionCount) * 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{divisionBars.length === 0 && <p className="text-sm text-ink-muted">Keine Daten vorhanden.</p>}
|
|
</div>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardTitle className="mb-1">Anstehend (60 Tage)</CardTitle>
|
|
<ul className="flex flex-col divide-y divide-border-subtle">
|
|
{upcoming.map((item) => (
|
|
<li key={`${item.kind}-${item.id}`}>
|
|
<Link
|
|
href={`/employees/${item.id}`}
|
|
className="-mx-2 flex items-center justify-between gap-2 rounded px-2 py-2.5 text-sm hover:bg-surface"
|
|
>
|
|
<span className="min-w-0">
|
|
<span className="block truncate font-semibold text-ink">{item.label}</span>
|
|
<span className="text-xs text-ink-muted">{KIND_LABEL[item.kind]}</span>
|
|
</span>
|
|
<span className="shrink-0 text-xs font-semibold tabular-nums text-ink-muted">{fmtDate(item.date)}</span>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
{upcoming.length === 0 && <p className="py-2 text-sm text-ink-muted">Keine anstehenden Ereignisse.</p>}
|
|
</ul>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardTitle className="mb-1">Letzte Aktivitäten</CardTitle>
|
|
<ul className="flex flex-col divide-y divide-border-subtle">
|
|
{(history).map((h) => (
|
|
<li key={h.id} className="flex gap-2.5 py-2.5">
|
|
{/* Dot aligned to the first line of text, not centred on the
|
|
whole row, so it stays put as descriptions wrap. */}
|
|
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
|
<span className="text-sm font-semibold text-ink">{h.first_name && h.last_name ? `${h.first_name} ${h.last_name}` : "Unbekannt"}</span>
|
|
<span className={`rounded-full px-2 py-0.5 text-[11px] font-semibold ${actionBadgeStyle(h.event_type)}`}>
|
|
{h.event_type}
|
|
</span>
|
|
</div>
|
|
<p className="mt-0.5 text-xs leading-relaxed text-ink-muted">{h.description}</p>
|
|
</div>
|
|
</li>
|
|
))}
|
|
{(history).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
|
|
</ul>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|