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 (
|
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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user