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 ( 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 && ( {!monitorModus && (
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <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>
);
}
@@ -12,6 +12,18 @@ const AKZENT_KLASSE: Record<TileAkzent, string> = {
danger: "border-l-[var(--color-danger)]", 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> { interface Props extends HTMLAttributes<HTMLDivElement> {
title: string; title: string;
akzent?: TileAkzent; akzent?: TileAkzent;
@@ -47,7 +59,19 @@ export const TileFrame = memo(
{...rest} {...rest}
> >
<CardHeader className="shrink-0"> <CardHeader className="shrink-0">
<div className="flex min-w-0 items-center gap-2">
<CardTitle>{title}</CardTitle> <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 && ( {bearbeitenAktiv && (
<Button <Button
variant="secondary" variant="secondary"
@@ -2,6 +2,7 @@ import { memo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { InventoryStatus } from "../../../components/status/InventoryStatus"; import { InventoryStatus } from "../../../components/status/InventoryStatus";
import { EmptyState } from "../EmptyState";
import type { Ablaufdatumeintrag } from "../types"; import type { Ablaufdatumeintrag } from "../types";
const ANZAHL_OHNE_AUFKLAPPEN = 5; const ANZAHL_OHNE_AUFKLAPPEN = 5;
@@ -14,7 +15,7 @@ function AblaufdatenTileKomponente({ ablaufdaten }: Props) {
const [alleAnzeigen, setAlleAnzeigen] = useState(false); const [alleAnzeigen, setAlleAnzeigen] = useState(false);
if (ablaufdaten.length === 0) { 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); 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 type { HistorieEintrag } from "../../../api/types";
import { EREIGNIS_LABEL } from "../../../components/status/ereignisText"; import { EREIGNIS_LABEL } from "../../../components/status/ereignisText";
import { EmptyState } from "../EmptyState";
interface Props { interface Props {
aktivitaet: HistorieEintrag[]; aktivitaet: HistorieEintrag[];
@@ -10,14 +11,17 @@ interface Props {
function AktivitaetTileKomponente({ aktivitaet }: Props) { function AktivitaetTileKomponente({ aktivitaet }: Props) {
if (aktivitaet.length === 0) { if (aktivitaet.length === 0) {
return <p className="text-muted">Noch keine Ereignisse.</p>; return <EmptyState text="Noch keine Ereignisse." />;
} }
return ( return (
<ul className="card-list"> <ul>
{aktivitaet.map((h) => ( {aktivitaet.map((h) => (
<li key={h.id} className="row-between" style={{ padding: "0.3rem 0" }}> <li
<span> 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} {EREIGNIS_LABEL[h.ereignistyp] ?? h.ereignistyp}
{h.entitaet_typ === "objekt" && ( {h.entitaet_typ === "objekt" && (
<> <>
@@ -25,9 +29,9 @@ function AktivitaetTileKomponente({ aktivitaet }: Props) {
<Link to={`/akte/objekt/${h.entitaet_id}`}>#{h.entitaet_id}</Link> <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>
<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> </li>
))} ))}
</ul> </ul>
@@ -42,17 +42,15 @@ function AufgabenTileKomponente({
].filter((a): a is { text: string; ziel: string | null } => a !== null); ].filter((a): a is { text: string; ziel: string | null } => a !== null);
if (aufgaben.length === 0) { if (aufgaben.length === 0) {
return ( return <p className="font-semibold text-[var(--color-success)]"> Nichts Dringendes offen.</p>;
<p className="text-success" style={{ fontWeight: 600 }}>
Nichts Dringendes offen.
</p>
);
} }
return ( return (
<ul className="card-list"> <ul>
{aufgaben.map((a) => ( {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> </ul>
); );
@@ -7,17 +7,27 @@ interface Props {
} }
function KennzahlenTileKomponente({ kennzahlen }: 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 ( return (
<div> <div>
<p className="text-4xl font-bold tabular-nums leading-none">{kennzahlen.anzahl_offener_fehlbestaende}</p> <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> <p className="mb-2 text-xs uppercase tracking-wide text-[var(--color-text-muted)]">Offene Fehlbestände</p>
<ul className="card-list"> <ul>
<li style={{ padding: "0.3rem 0" }}>Genehmigte Mindermengen: {kennzahlen.anzahl_genehmigter_mindermengen}</li> {zeilen.map((z) => (
<li style={{ padding: "0.3rem 0" }}> <li
Kürzlich erledigt (7 Tage): {kennzahlen.anzahl_kuerzlich_erledigter_fehlbestaende} 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> </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>
</ul> </ul>
</div> </div>
); );
@@ -2,6 +2,8 @@ import { memo, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Mangel } from "../../../api/types"; import type { Mangel } from "../../../api/types";
import { cn } from "../../../lib/utils";
import { EmptyState } from "../EmptyState";
interface Props { interface Props {
maengel: Mangel[]; maengel: Mangel[];
@@ -25,22 +27,24 @@ function MaengelTileKomponente({ maengel, maengelNeuAnzahl, maengelKritischAnzah
)} )}
{maengelOffenListe.length > 0 && ( {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"} {maengelOffen ? "Details ausblenden" : "Details anzeigen"}
</button> </button>
{maengelOffen && ( {maengelOffen && (
<ul className="card-list" style={{ marginTop: "0.5rem" }}> <ul className="mt-2">
{maengelOffenListe.map((m) => ( {maengelOffenListe.map((m) => (
<li key={m.id} className="row-between" style={{ padding: "0.3rem 0" }}> <li
<span> 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> <Link to="/maengel">{m.beschreibung}</Link>
{m.status === "neu" && ( {m.status === "neu" && <span className="ml-1.5 text-[var(--color-warning)]">neu</span>}
<span className="text-muted" style={{ color: "var(--color-warning)", marginLeft: "0.4rem" }}>
neu
</span> </span>
)} <span className={cn("shrink-0", m.prioritaet === "hoch" && "text-[var(--color-danger)]")}>
</span>
<span className="text-muted" style={{ color: m.prioritaet === "hoch" ? "var(--color-danger)" : undefined }}>
{m.prioritaet} {m.prioritaet}
</span> </span>
</li> </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 { Link } from "react-router-dom";
import { InspectionStatus } from "../../../components/status/InspectionStatus"; import { InspectionStatus } from "../../../components/status/InspectionStatus";
import { EmptyState } from "../EmptyState";
import type { Prueftermineintrag } from "../types"; import type { Prueftermineintrag } from "../types";
const ANZAHL_OHNE_AUFKLAPPEN = 5; const ANZAHL_OHNE_AUFKLAPPEN = 5;
@@ -14,7 +15,7 @@ function PrueftermineTileKomponente({ prueftermine }: Props) {
const [alleAnzeigen, setAlleAnzeigen] = useState(false); const [alleAnzeigen, setAlleAnzeigen] = useState(false);
if (prueftermine.length === 0) { 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); const sichtbare = alleAnzeigen ? prueftermine : prueftermine.slice(0, ANZAHL_OHNE_AUFKLAPPEN);
@@ -1,6 +1,7 @@
import { memo, useState } from "react"; import { memo, useState } from "react";
import { FristBadge } from "../../../components/status/FristBadge"; import { FristBadge } from "../../../components/status/FristBadge";
import { EmptyState } from "../EmptyState";
import type { Qualifikationsablauf } from "../types"; import type { Qualifikationsablauf } from "../types";
const ANZAHL_OHNE_AUFKLAPPEN = 5; const ANZAHL_OHNE_AUFKLAPPEN = 5;
@@ -13,7 +14,7 @@ function QualifikationTileKomponente({ qualifikationsablaeufe }: Props) {
const [alleAnzeigen, setAlleAnzeigen] = useState(false); const [alleAnzeigen, setAlleAnzeigen] = useState(false);
if (qualifikationsablaeufe.length === 0) { 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); const sichtbare = alleAnzeigen ? qualifikationsablaeufe : qualifikationsablaeufe.slice(0, ANZAHL_OHNE_AUFKLAPPEN);
+11
View File
@@ -288,6 +288,17 @@ a {
padding: 1.75rem 2rem 3rem; padding: 1.75rem 2rem 3rem;
} }
/* Status-Monitor-Modus (Dashboard dauerhaft auf Wandmonitor, Betrachtung aus
mehreren Metern Entfernung) - alles per zoom vergrößern statt einzelne
Tailwind-rem-Klassen zu duplizieren (rem ist immer relativ zum Root-<html>,
Container-Schriftgröße ändern hätte also keine Wirkung auf Tailwind-Text-
/Abstandsklassen). zoom skaliert das gesamte gerenderte Layout inkl.
react-grid-layout-Raster gemeinsam mit den echten Pixelmaßen. */
[data-monitor="true"] {
max-width: none;
zoom: 1.3;
}
/* -- Cards ---------------------------------------------------------------- */ /* -- Cards ---------------------------------------------------------------- */
.card { .card {