feat(dashboard): Design-Review umgesetzt - EmptyStates, Statuslabel, Monitor-Skalierung
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -288,6 +288,17 @@ a {
|
||||
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 ---------------------------------------------------------------- */
|
||||
|
||||
.card {
|
||||
|
||||
Reference in New Issue
Block a user