Files
alpenwerk-hr/app/(app)/page.tsx
Maximilian Stubhan 27669e0359 Put the whole application on the OM model, and delete what it replaced
Die Datenbank stand seit dem Cut-over auf org_units/om_positions/
position_assignments, die Anwendung fragte weiter nach employees.division_id,
team_id und manager_id — Spalten, die es nicht mehr gab. Die Oberfläche war
deshalb leer, obwohl die Daten vollständig da waren. Das ist jetzt behoben,
und zwar nicht durch Nachbau der alten Begriffe, sondern indem sie verschwinden.

Neu ist eine dünne Schicht, die die Verkettung Person → Besetzung →
Planstelle → Einheit einmal auflöst (lib/placement.ts) und der Baum als reine
Funktionen darauf (lib/org.ts): Vorfahrenkette, Teilbaum, Brotkrume. Alles
Weitere hängt daran.

Was sich dadurch von selbst erledigt hat:

  - Das Organigramm musste drei Quellen versöhnen, weil keine den ganzen
    Zeitstrahl abdeckte. position_assignments ist zeitabhängig, also
    beantwortet eine Abfrage "wer besetzte am Stichtag welche Planstelle" —
    für Vergangenheit und Zukunft gleichermassen. Wer keine Planstelle hatte,
    war nicht da; eine zweite Zugehörigkeitsregel braucht es nicht mehr.
  - Die Struktursicht war auf genau vier Ebenen verdrahtet und rendert jetzt
    rekursiv über parent_id. Liste und Grafik entstehen aus *einem* Baum;
    vorher lag dieselbe Hierarchie zweimal vor und konnte auseinanderlaufen.
  - Eine offene Stelle ist keine eigene Tabelle mehr, sondern eine Planstelle
    ohne laufende Besetzung — das Komplement kann nicht aus dem Tritt geraten.
  - Eine Versetzung ist der Wechsel auf eine Zielplanstelle statt Zielteam
    plus frei getipptem Titel. Sie kann damit nicht mehr dort landen, wo es
    keine Stelle gibt, und die Tätigkeit kommt aus dem Job-Katalog.
  - Beim Anlegen einer Planstelle entfällt die Suche nach der vorgesetzten
    Person: sie ergibt sich aus der Einheit, die Frage kann nicht mehr falsch
    beantwortet werden.

Zwei Auswertungen werden dabei richtiger, nicht nur anders. Ein
Stichtagsbericht gruppierte bisher nach der *heutigen* Zuordnung, weil es
keine Historie gab; er löst sie jetzt zum Stichtag auf. Und ein Ereignis
trägt die Einheit, in der die Person am Tag des Ereignisses sass — vorher
stand ein Austritt von vor zwei Jahren unter einem Team, in das sie nie
versetzt worden war. Der Bereichsfilter greift überall auf den ganzen
Teilbaum; auf den Bereich allein angewandt lieferte er nur die
Bereichsleitung.

Gelöscht: die Reorganisations-Werkbank samt Szenarien und Zügen (sie
verschob Teams und Abteilungen zwischen Bereichen — Objekte, die es nicht
mehr gibt; im OM-Modell ist das ein Umhängen von parent_id), die
Mitarbeiter- und Vorgesetztensuche, die nur sie und die Ausschreibung
brauchten, und aus lib/supabase/types.ts die Tabellen divisions,
departments, teams, positions und employee_assignments.

Die beiliegende Migration räumt die Datenbank entsprechend auf. Sie entfernt
auch Funktionen, die der Cut-over verfehlt hat: create_position,
delete_position und undo_reorg existierten zusätzlich in einer
jsonb-Variante und tauchen deshalb weiter in der PostgREST-Schnittstelle auf,
obwohl ihre Tabellen weg sind — ein Aufruf wäre erst zur Laufzeit
gescheitert. An ihre Stelle treten create_position und delete_position im
OM-Sinn; letzteres schliesst eine früher besetzte Planstelle, statt sie zu
löschen, sonst verschwände mit ihr die Besetzungshistorie.

Typecheck, Lint, Build und 182 Tests sind grün. Die Integrationstests sind
mitgezogen, aber weiterhin ungelaufen — dafür braucht es eine laufende
lokale Datenbank.
2026-07-27 20:02:26 +02:00

326 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 { createClient } from "@/lib/supabase/server";
import { fetchAllRows } from "@/lib/supabase/query";
// 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() {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
const { data: drafts } = user
? await supabase
.from("hire_drafts")
.select("id, step, payload, updated_at")
.eq("created_by", user.id)
.order("updated_at", { ascending: false })
: { data: [] };
// Built as strings, not by round-tripping a local Date through
// toISOString(): in any positive-offset zone new Date(year, 0, 1) is still
// the previous year in UTC, which shifted the whole YTD window a day early
// 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);
// 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.
// It also replaces four separate count queries with one.
const [
staffRows,
hiresYtdRes,
exitsYtdRes,
openPositions,
orgMaps,
placements,
upcomingHiresRes,
upcomingExitsRes,
upcomingReturnsRes,
historyRes,
] = await Promise.all([
fetchAllRows(() =>
supabase
.from("employees")
.select("id, weekly_hours, entry_date, exit_date, karenz_start_date, karenz_return_date")
.order("id")
),
// Entries/exits count history events, which is what the linked report
// counts too. `entry_date` would also sweep up rehires, whose event is
// logged as 'Wiedereintritt' — the tile and its destination then showed
// different numbers for the same year.
supabase
.from("employee_history")
.select("id", { count: "exact", head: true })
.in("event_type", ["Eintritt", "Wiedereintritt"])
.gte("event_date", yearStart)
.lte("event_date", yearEnd),
supabase
.from("employee_history")
.select("id", { count: "exact", head: true })
.eq("event_type", "Austritt")
.gte("event_date", yearStart)
.lte("event_date", yearEnd),
loadOpenPositions(supabase),
loadOrgMaps(supabase),
loadPlacements(supabase, { asOf: today }),
supabase
.from("employees")
.select("id, first_name, last_name, entry_date")
.eq("status", "Geplant")
.gte("entry_date", today)
.lte("entry_date", in60Iso),
supabase
.from("employees")
.select("id, first_name, last_name, exit_date")
.not("exit_date", "is", null)
.gte("exit_date", today)
.lte("exit_date", in60Iso),
supabase
.from("employees")
.select("id, first_name, last_name, karenz_return_date")
.eq("status", "Karenz")
.not("karenz_return_date", "is", null)
.gte("karenz_return_date", today)
.lte("karenz_return_date", in60Iso),
supabase
.from("employee_history")
.select("id, employee_id, event_date, event_type, description")
.order("event_date", { ascending: false })
.order("created_at", { ascending: false })
.limit(10),
]);
// "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[] = [
...(upcomingHiresRes.data ?? []).map((e) => ({
id: e.id,
label: `${e.first_name} ${e.last_name}`,
date: e.entry_date,
kind: "hire" as const,
})),
...(upcomingExitsRes.data ?? []).map((e) => ({
id: e.id,
label: `${e.first_name} ${e.last_name}`,
date: e.exit_date!,
kind: "exit" as const,
})),
...(upcomingReturnsRes.data ?? []).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);
const historyEmployeeIds = Array.from(new Set((historyRes.data ?? []).map((h) => h.employee_id)));
const historyEmployeesRes = historyEmployeeIds.length
? await supabase.from("employees").select("id, first_name, last_name").in("id", historyEmployeeIds)
: { data: [] as { id: string; first_name: string; last_name: string }[] };
const employeeNameById = new Map((historyEmployeesRes.data ?? []).map((e) => [e.id, `${e.first_name} ${e.last_name}`]));
// Each tile links to the view that shows what it counts, with the filters
// pre-applied.
//
// 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: hiresYtdRes.count ?? 0,
tone: "success",
href: `/reports?mode=events&eventType=Eintritt&from=${yearStart}&to=${yearEnd}`,
},
{
label: "Austritte (Jahr)",
value: exitsYtdRes.count ?? 0,
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">
{(historyRes.data ?? []).map((h) => (
<li key={h.id} className="flex gap-2.5 py-2.5">
{/* Dot aligned to the first line of text, not centred on the
whole row, so it stays put as descriptions wrap. */}
<span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${DOT_STYLES[h.event_type] ?? "bg-ink-muted"}`} aria-hidden />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-semibold text-ink">{employeeNameById.get(h.employee_id) ?? "Unbekannt"}</span>
<span className={`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>
))}
{(historyRes.data ?? []).length === 0 && <p className="py-2 text-sm text-ink-muted">Keine Aktivitäten vorhanden.</p>}
</ul>
</Card>
</div>
</div>
);
}