feat(dashboard): Design-Review umgesetzt - EmptyStates, Statuslabel, Monitor-Skalierung
CI / backend-tests (push) Failing after 2m11s
CI / frontend-build (push) Successful in 22s

Einheitliche EmptyState-Komponente statt uneinheitlicher Empty-Texte pro
Kachel, gemeinsames Listen-Pattern (Inline-Styles entfernt, u.a.
KennzahlenTile, MaengelTile, AktivitaetTile, AufgabenTile), Status-Textlabel
im Kachel-Header zusätzlich zur Randfarbe (Kontrast/Distanz/Farbfehlsichtigkeit),
Status-Monitor-Modus jetzt per zoom vergrößert für Wandmonitor-Lesbarkeit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
2026-09-07 22:38:36 +02:00
co-authored by Claude Sonnet 5
parent 68f0139b3d
commit d38c7adbc7
11 changed files with 106 additions and 37 deletions
@@ -210,7 +210,7 @@ export function DashboardPage() {
);
return (
<main className={cn("page-wide", monitorModus && "bg-background min-h-screen")}>
<main className={cn("page-wide", monitorModus && "bg-background min-h-screen")} data-monitor={monitorModus}>
{!monitorModus && (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
@@ -0,0 +1,15 @@
interface Props {
text: string;
icon?: string;
}
export function EmptyState({ text, icon = "✓" }: Props) {
return (
<div className="flex h-full min-h-16 flex-col items-center justify-center gap-1 py-4 text-center">
<span aria-hidden className="text-xl text-[var(--color-text-muted)]">
{icon}
</span>
<p className="text-sm text-[var(--color-text-muted)]">{text}</p>
</div>
);
}
+25 -1
View File
@@ -12,6 +12,18 @@ const AKZENT_KLASSE: Record<TileAkzent, string> = {
danger: "border-l-[var(--color-danger)]",
};
// Statusfarbe nie allein über die Randfarbe transportieren (Distanz/Wandmonitor,
// Farbfehlsichtigkeit) - zusätzliches Text-Label neben dem Titel.
const AKZENT_LABEL: Record<Exclude<TileAkzent, "neutral">, string> = {
warning: "Achtung",
danger: "Kritisch",
};
const AKZENT_LABEL_KLASSE: Record<Exclude<TileAkzent, "neutral">, string> = {
warning: "bg-[var(--color-warning)]/15 text-[var(--color-warning)]",
danger: "bg-[var(--color-danger)]/15 text-[var(--color-danger)]",
};
interface Props extends HTMLAttributes<HTMLDivElement> {
title: string;
akzent?: TileAkzent;
@@ -47,7 +59,19 @@ export const TileFrame = memo(
{...rest}
>
<CardHeader className="shrink-0">
<CardTitle>{title}</CardTitle>
<div className="flex min-w-0 items-center gap-2">
<CardTitle>{title}</CardTitle>
{akzent !== "neutral" && (
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-xs font-semibold uppercase tracking-wide",
AKZENT_LABEL_KLASSE[akzent]
)}
>
{AKZENT_LABEL[akzent]}
</span>
)}
</div>
{bearbeitenAktiv && (
<Button
variant="secondary"
@@ -2,6 +2,7 @@ import { memo, useState } from "react";
import { Link } from "react-router-dom";
import { InventoryStatus } from "../../../components/status/InventoryStatus";
import { EmptyState } from "../EmptyState";
import type { Ablaufdatumeintrag } from "../types";
const ANZAHL_OHNE_AUFKLAPPEN = 5;
@@ -14,7 +15,7 @@ function AblaufdatenTileKomponente({ ablaufdaten }: Props) {
const [alleAnzeigen, setAlleAnzeigen] = useState(false);
if (ablaufdaten.length === 0) {
return <p className="text-sm text-[var(--color-text-muted)]">Keine bald ablaufenden Chargen.</p>;
return <EmptyState text="Keine bald ablaufenden Chargen." />;
}
const sichtbare = alleAnzeigen ? ablaufdaten : ablaufdaten.slice(0, ANZAHL_OHNE_AUFKLAPPEN);
@@ -3,6 +3,7 @@ import { Link } from "react-router-dom";
import type { HistorieEintrag } from "../../../api/types";
import { EREIGNIS_LABEL } from "../../../components/status/ereignisText";
import { EmptyState } from "../EmptyState";
interface Props {
aktivitaet: HistorieEintrag[];
@@ -10,14 +11,17 @@ interface Props {
function AktivitaetTileKomponente({ aktivitaet }: Props) {
if (aktivitaet.length === 0) {
return <p className="text-muted">Noch keine Ereignisse.</p>;
return <EmptyState text="Noch keine Ereignisse." />;
}
return (
<ul className="card-list">
<ul>
{aktivitaet.map((h) => (
<li key={h.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
<li
key={h.id}
className="flex items-center justify-between gap-2 border-b border-[var(--color-border)] py-1.5 text-sm last:border-0"
>
<span className="min-w-0 truncate">
{EREIGNIS_LABEL[h.ereignistyp] ?? h.ereignistyp}
{h.entitaet_typ === "objekt" && (
<>
@@ -25,9 +29,9 @@ function AktivitaetTileKomponente({ aktivitaet }: Props) {
<Link to={`/akte/objekt/${h.entitaet_id}`}>#{h.entitaet_id}</Link>
</>
)}
{h.benutzer_name && <span className="text-muted"> · {h.benutzer_name}</span>}
{h.benutzer_name && <span className="text-[var(--color-text-muted)]"> · {h.benutzer_name}</span>}
</span>
<span className="text-muted">{new Date(h.zeitpunkt).toLocaleString("de-DE")}</span>
<span className="shrink-0 text-[var(--color-text-muted)]">{new Date(h.zeitpunkt).toLocaleString("de-DE")}</span>
</li>
))}
</ul>
@@ -42,17 +42,15 @@ function AufgabenTileKomponente({
].filter((a): a is { text: string; ziel: string | null } => a !== null);
if (aufgaben.length === 0) {
return (
<p className="text-success" style={{ fontWeight: 600 }}>
Nichts Dringendes offen.
</p>
);
return <p className="font-semibold text-[var(--color-success)]"> Nichts Dringendes offen.</p>;
}
return (
<ul className="card-list">
<ul>
{aufgaben.map((a) => (
<li key={a.text}>{a.ziel ? <Link to={a.ziel}>{a.text}</Link> : <span>{a.text}</span>}</li>
<li key={a.text} className="border-b border-[var(--color-border)] py-1.5 text-sm last:border-0">
{a.ziel ? <Link to={a.ziel}>{a.text}</Link> : <span>{a.text}</span>}
</li>
))}
</ul>
);
@@ -7,17 +7,27 @@ interface Props {
}
function KennzahlenTileKomponente({ kennzahlen }: Props) {
const zeilen = [
{ label: "Genehmigte Mindermengen", wert: kennzahlen.anzahl_genehmigter_mindermengen },
{ label: "Kürzlich erledigt (7 Tage)", wert: kennzahlen.anzahl_kuerzlich_erledigter_fehlbestaende },
{ label: "Problematische Objekte", wert: kennzahlen.anzahl_problematischer_objekte },
{ label: "Objekte gesamt", wert: kennzahlen.anzahl_objekte_gesamt },
];
return (
<div>
<p className="text-4xl font-bold tabular-nums leading-none">{kennzahlen.anzahl_offener_fehlbestaende}</p>
<p className="mb-3 text-xs uppercase tracking-wide text-[var(--color-text-muted)]">Offene Fehlbestände</p>
<ul className="card-list">
<li style={{ padding: "0.3rem 0" }}>Genehmigte Mindermengen: {kennzahlen.anzahl_genehmigter_mindermengen}</li>
<li style={{ padding: "0.3rem 0" }}>
Kürzlich erledigt (7 Tage): {kennzahlen.anzahl_kuerzlich_erledigter_fehlbestaende}
</li>
<li style={{ padding: "0.3rem 0" }}>Problematische Objekte: {kennzahlen.anzahl_problematischer_objekte}</li>
<li style={{ padding: "0.3rem 0" }}>Objekte gesamt: {kennzahlen.anzahl_objekte_gesamt}</li>
<p className="mb-2 text-xs uppercase tracking-wide text-[var(--color-text-muted)]">Offene Fehlbestände</p>
<ul>
{zeilen.map((z) => (
<li
key={z.label}
className="flex items-center justify-between gap-2 border-b border-[var(--color-border)] py-1.5 text-sm last:border-0"
>
<span className="text-[var(--color-text-muted)]">{z.label}</span>
<span className="shrink-0 font-semibold tabular-nums">{z.wert}</span>
</li>
))}
</ul>
</div>
);
@@ -2,6 +2,8 @@ import { memo, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import type { Mangel } from "../../../api/types";
import { cn } from "../../../lib/utils";
import { EmptyState } from "../EmptyState";
interface Props {
maengel: Mangel[];
@@ -25,22 +27,24 @@ function MaengelTileKomponente({ maengel, maengelNeuAnzahl, maengelKritischAnzah
)}
{maengelOffenListe.length > 0 && (
<>
<button className="btn btn-secondary" onClick={() => setMaengelOffen(!maengelOffen)}>
<button
className="mt-1 text-sm font-medium text-[var(--color-primary)] underline-offset-2 hover:underline"
onClick={() => setMaengelOffen(!maengelOffen)}
>
{maengelOffen ? "Details ausblenden" : "Details anzeigen"}
</button>
{maengelOffen && (
<ul className="card-list" style={{ marginTop: "0.5rem" }}>
<ul className="mt-2">
{maengelOffenListe.map((m) => (
<li key={m.id} className="row-between" style={{ padding: "0.3rem 0" }}>
<span>
<li
key={m.id}
className="flex items-center justify-between gap-2 border-b border-[var(--color-border)] py-1.5 text-sm last:border-0"
>
<span className="min-w-0 truncate">
<Link to="/maengel">{m.beschreibung}</Link>
{m.status === "neu" && (
<span className="text-muted" style={{ color: "var(--color-warning)", marginLeft: "0.4rem" }}>
neu
</span>
)}
{m.status === "neu" && <span className="ml-1.5 text-[var(--color-warning)]">neu</span>}
</span>
<span className="text-muted" style={{ color: m.prioritaet === "hoch" ? "var(--color-danger)" : undefined }}>
<span className={cn("shrink-0", m.prioritaet === "hoch" && "text-[var(--color-danger)]")}>
{m.prioritaet}
</span>
</li>
@@ -49,7 +53,7 @@ function MaengelTileKomponente({ maengel, maengelNeuAnzahl, maengelKritischAnzah
)}
</>
)}
{maengelOffenListe.length === 0 && <p className="text-muted">Keine offenen Mängel.</p>}
{maengelOffenListe.length === 0 && <EmptyState text="Keine offenen Mängel." />}
</>
);
}
@@ -2,6 +2,7 @@ import { memo, useState } from "react";
import { Link } from "react-router-dom";
import { InspectionStatus } from "../../../components/status/InspectionStatus";
import { EmptyState } from "../EmptyState";
import type { Prueftermineintrag } from "../types";
const ANZAHL_OHNE_AUFKLAPPEN = 5;
@@ -14,7 +15,7 @@ function PrueftermineTileKomponente({ prueftermine }: Props) {
const [alleAnzeigen, setAlleAnzeigen] = useState(false);
if (prueftermine.length === 0) {
return <p className="text-sm text-[var(--color-text-muted)]">Keine anstehenden oder überfälligen Geräteprüfungen.</p>;
return <EmptyState text="Keine anstehenden oder überfälligen Geräteprüfungen." />;
}
const sichtbare = alleAnzeigen ? prueftermine : prueftermine.slice(0, ANZAHL_OHNE_AUFKLAPPEN);
@@ -1,6 +1,7 @@
import { memo, useState } from "react";
import { FristBadge } from "../../../components/status/FristBadge";
import { EmptyState } from "../EmptyState";
import type { Qualifikationsablauf } from "../types";
const ANZAHL_OHNE_AUFKLAPPEN = 5;
@@ -13,7 +14,7 @@ function QualifikationTileKomponente({ qualifikationsablaeufe }: Props) {
const [alleAnzeigen, setAlleAnzeigen] = useState(false);
if (qualifikationsablaeufe.length === 0) {
return <p className="text-sm text-[var(--color-text-muted)]">Keine bald ablaufenden Qualifikationen.</p>;
return <EmptyState text="Keine bald ablaufenden Qualifikationen." />;
}
const sichtbare = alleAnzeigen ? qualifikationsablaeufe : qualifikationsablaeufe.slice(0, ANZAHL_OHNE_AUFKLAPPEN);