Files
Dashboard/features/dashboard/pages/TeamSeite.tsx
T
dejanandClaude Opus 5 ffbe70aede Zeilen nur strecken, wenn die Karten dieselbe Bauart haben
Rueckbau des letzten Commits, soweit er zu weit ging. Er hat alle
Diagrammzeilen gestreckt; richtig ist das nur dort, wo die Karten von Natur
aus aehnlich hoch sind.

Gemessen bei 1680 px, Hoehenunterschied innerhalb einer Zeile:

  Trichter | Auslastung          (Liste | Liste)        0 px /   0 px
  Zahlungseingang | Bestand      (Chart | Chart)        0 px /   0 px
  Zahlungseingang | Gewerk       (Chart | Liste)      147 px / 315 px
  Vorgaenge Status | Gewerk      (Chart | Liste)      190 px / 358 px
                                            (volle Daten / duenne Daten)

Der Grund steckt in der Liste: Eine Balkenliste hat keine Mindesthoehe. Fuenf
Kategorien ergeben 280 px, eine einzige 112 px — daneben liegt ein
Zeitverlauf unveraendert bei 427 px, weil seine Diagrammflaeche fest ist.
Diese Zeile zu strecken heisst, eine Karte mit einem Balken auf 427 px
aufzublasen. Das ist schlimmer als die Luecke, die es vermeiden soll:
Weissraum auf der Seite liest sich als "hier ist gerade wenig", eine riesige
Karte mit einem Balken darin als Fehler.

items-start kehrt deshalb auf allen Zeilen zurueck, in denen ein Diagramm mit
fester Hoehe neben einer Liste steht. Gestreckt bleibt, wo beide Karten
dieselbe Bauart haben — Trichter neben Auslastung.

Das senkrechte Zentrieren im Kartenkoerper bleibt: Auf oben buendigen Zeilen
ist es wirkungslos, auf den gestreckten verteilt es den Ueberschuss oben und
unten statt ihn unten abzuladen. Das war die Verbesserung am Trichter.

Auf dem Dashboard bleibt die Zeile aus Zeitverlauf und Balkenliste gestreckt.
Derselbe Massstab, anderes Ergebnis: Dort gibt es keinen Filter, die Liste hat
immer ihre fuenf Gewerke, und es bleiben rund 90 px. Der Filter ist der
Unterschied, nicht die Kachel.

DESIGN-SYSTEM.md traegt jetzt die Regel mit den Messwerten statt der zu
weit gefassten Fassung von vorhin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-31 17:48:05 +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>
</>
);
}