Files
Dashboard/features/dashboard/pages/TeamSeite.tsx
T
dejanandClaude Opus 5 e85ceb6f9d feat(nav): Seitenleiste der Software spiegeln, Analytics als ein Menüpunkt
Der Prototyp hatte eine eigene Seitenleiste mit den fünf Analyse-Seiten als
Menüpunkten. Die Software hat eine andere Navigation, in der Analytics noch gar
nicht vorkommt. Beides zusammen ergab kein Bild davon, wo die Analyse später
sitzt.

Jetzt:
- Die Seitenleiste spiegelt die von HiveGrid Cloud — dieselbe Komponente
  (SidebarWithSections aus der Cloud-Fassung portiert, inkl. der dortigen
  lokalen Fixes: Collapse-Breite, verdrahteter Toggle, Logo-Slot, Hover-Sweep),
  dieselben Gruppen, dieselbe Reihenfolge, dasselbe Logo.
- 'Analytics' ist ein einzelner Menüpunkt, gleichrangig neben 'Dashboard'.
- Die fünf Analyse-Seiten sind darin über Reiter erreichbar, nicht als
  Unterpunkte in der Navigation. Zwei Navigationsebenen für dieselbe Sache
  würden nur konkurrieren. Die Reiter folgen dem Hausmuster aus
  SettingsHubShell (Link um Tabs.Item), damit jeder Reiter eine echte Adresse
  hat und mittelklickbar bleibt.
- Routen: /analytics, /analytics/finanzen, /auftraege, /kunden, /team.
  '/' leitet auf /analytics; das Dashboard liegt in der Software unter '/'.
- Alle übrigen Einträge sind Kontext: Sie zeigen den Ort in der fertigen
  Anwendung und melden beim Klick, dass sie zur Software gehören.

Kopfzeile und Filterleiste sind aus den fünf Seiten in die Analytics-Hülle
gewandert — sie sind für alle gleich, und der Filterzustand gilt bereichsweit.

Nebenbei: @radix-ui/react-slot war eine Phantom-Dependency (nur transitiv über
@subframe/core vorhanden, vom portierten IconButton direkt importiert) und ist
jetzt deklariert. verify-layout prüfte noch die alten Routen und lief damit auf
404-Seiten, die naturgemäß keine Kollisionen haben — Routen korrigiert und eine
Absicherung ergänzt, die bei Statuscode >= 400 abbricht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-29 20:25:04 +02:00

211 lines
6.9 KiB
TypeScript

"use client";
import React from "react";
import { PageContainer, Raster } from "../components/layout/PageContainer";
import { KPICard } from "../components/cards/KPICard";
import { ChartCard } from "../components/cards/ChartCard";
import { AuslastungsBalken } from "../components/charts/AuslastungsBalken";
import { BalkenHorizontal } from "../components/charts/BalkenHorizontal";
import { Donut } from "../components/charts/Donut";
import { DataTable, type Spalte } from "../components/tables/DataTable";
import { formatEuro, formatEuroKompakt, formatProzent, formatStunden, formatZahl } from "../utils/formatters";
import { SeitenZustand } from "../components/layout/SeitenZustand";
import type { VMState } from "../view-models";
import type { TeamDaten, TeamZeile } from "../view-models/team.vm";
export function TeamSeite({ vm }: { vm: VMState<TeamDaten> }) {
if (vm.status !== "ready") {
return <SeitenZustand vm={vm} />;
}
const d = vm.data;
const spalten: Spalte<TeamZeile>[] = [
{
key: "name",
titel: "Mitarbeiter",
breite: "26%",
zelle: (k) => (
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 flex-none items-center justify-center rounded-full bg-neutral-100 text-[11px] font-semibold text-text-neutral-secondary">
{k.mitarbeiter.kuerzel}
</span>
<div className="flex flex-col">
<span className="font-medium">{k.mitarbeiter.name}</span>
<span className="text-[11px] text-text-neutral-tertiary">
{k.mitarbeiter.rolle} · {k.mitarbeiter.gewerk}
</span>
</div>
</div>
),
sortWert: (k) => k.mitarbeiter.name,
},
{
key: "status",
titel: "Status",
zelle: (k) =>
k.mitarbeiter.status === "aktiv" ? (
<span className="rounded-full bg-[#defbe6] px-2 py-0.5 text-[11px] font-medium text-[#0e6027]">
aktiv
</span>
) : (
<span className="rounded-full bg-neutral-100 px-2 py-0.5 text-[11px] font-medium text-neutral-600">
inaktiv
</span>
),
sortWert: (k) => k.mitarbeiter.status,
},
{
key: "stunden",
titel: "Stunden",
numerisch: true,
zelle: (k) => formatStunden(k.stunden),
sortWert: (k) => k.stunden,
},
{
key: "produktiv",
titel: "davon produktiv",
numerisch: true,
zelle: (k) => formatStunden(k.produktivStunden),
sortWert: (k) => k.produktivStunden,
},
{
key: "auslastung",
titel: "Auslastung",
numerisch: true,
zelle: (k) => (
<span
className={
k.auslastungProzent > 95
? "font-medium text-[#8a3800]"
: k.auslastungProzent < 70
? "text-[#00539a]"
: "font-medium text-[#0e6027]"
}
>
{formatProzent(k.auslastungProzent, 0)}
</span>
),
sortWert: (k) => k.auslastungProzent,
},
{
key: "abgeschlossen",
titel: "Abgeschlossen",
numerisch: true,
zelle: (k) => formatZahl(k.abgeschlossen),
sortWert: (k) => k.abgeschlossen,
},
{
key: "umsatz",
titel: "Auftragswert",
numerisch: true,
zelle: (k) => <span className="font-medium">{formatEuro(k.umsatz)}</span>,
sortWert: (k) => k.umsatz,
},
];
return (
<>
<PageContainer>
<Raster spalten={5}>
<KPICard
label="Im Einsatz"
wert={formatZahl(d.aktive.length)}
zusatz={`von ${formatZahl(d.mitarbeiterGesamt)} gewerblich Beschäftigten`}
icon="HardHat"
bezug="stichtag"
/>
<KPICard
label="Ø Auslastung"
wert={formatProzent(d.durchschnittAuslastung, 0)}
zusatz="Produktive Stunden zu verfügbaren"
icon="Activity"
akzent={d.durchschnittAuslastung > 95 ? "warnung" : "positiv"}
/>
<KPICard
label="Produktivquote"
wert={formatProzent(d.produktivQuote, 0)}
zusatz="Anteil auftragsbezogener Stunden"
icon="Target"
/>
<KPICard
label="Erfasste Stunden"
wert={formatStunden(d.stundenGesamt)}
zusatz={`${formatStunden(d.produktivGesamt)} auf Aufträge gebucht`}
icon="Clock"
/>
<KPICard
label="Abwesenheit"
wert={formatStunden(d.abwesenheitStunden)}
zusatz="Urlaub, Krankheit, Fortbildung"
icon="CalendarOff"
richtungGut="negativ"
/>
</Raster>
<ChartCard
id="team-auslastung"
titel="Auslastung je Mitarbeiter"
untertitel="Produktive Stunden gegen vertraglich verfügbare Stunden"
hinweis="Werte über 100 % bedeuten Überstunden. Dauerhaft über 95 % ist ein Warnsignal für Terminrisiken."
>
<AuslastungsBalken
zeilen={d.aktive.map((k) => ({
name: k.mitarbeiter.name,
wert: k.auslastungProzent,
zusatz: `${k.mitarbeiter.rolle} · ${k.mitarbeiter.gewerk}`,
}))}
maxZeilen={12}
/>
</ChartCard>
<div className="grid grid-cols-1 items-start gap-4 lg:grid-cols-2">
<ChartCard
id="team-abschluesse"
titel="Abschlüsse je Mitarbeiter"
untertitel="Anzahl im gewählten Zeitraum, dahinter der zugehörige Auftragswert"
hinweis="Der Balken zeigt die Anzahl — sie ist die vergleichbare Größe. Der Auftragswert steht daneben, ist aber ausdrücklich kein Leistungsvergleich: Ein Wärmepumpen-Auftrag ist um ein Vielfaches größer als eine Wartung."
>
<BalkenHorizontal
daten={d.aktive.map((k) => ({
name: k.mitarbeiter.name,
value: k.abgeschlossen,
zweitwert: k.umsatz,
}))}
formatWert={(w) => formatZahl(w)}
formatZweitwert={formatEuroKompakt}
maxBalken={8}
/>
</ChartCard>
<ChartCard
id="team-abwesenheit"
titel="Abwesenheiten"
untertitel="Stunden im gewählten Zeitraum"
>
<Donut
daten={d.abwesenheitNach}
formatWert={(w) => formatStunden(w)}
hoehe={210}
mitteLabel="Stunden"
/>
</ChartCard>
</div>
<ChartCard
id="team-tabelle"
titel="Mitarbeiterübersicht"
untertitel="Alle gewerblich Beschäftigten mit Kennzahlen"
>
<DataTable
zeilen={d.zeilen}
spalten={spalten}
proSeite={10}
suchFelder={(k) => `${k.mitarbeiter.name} ${k.mitarbeiter.rolle} ${k.mitarbeiter.gewerk}`}
/>
</ChartCard>
</PageContainer>
</>
);
}