feat(dashboard): Tailwind CSS + shadcn/ui eingeführt, Dashboard modernisiert
CI / backend-tests (push) Failing after 2m8s
CI / frontend-build (push) Successful in 20s

Kachel-Größe/Ausblenden-Fix (flexBasis pro Kachel statt nur Aufgaben-Kachel, sichtbare Liste ausgeblendeter Kacheln zum Zurückholen). Tailwind v4 + shadcn-Basiskomponenten (Button/Card/Badge) neu eingeführt, gezielt fürs Dashboard-Redesign genutzt (kein Preflight, global.css bleibt für restliche App unangetastet). TileFrame nutzt jetzt Card mit farbigem Akzentbalken je Kritikalität (Mängel/Prüftermine/Ablaufdaten/Einsatzbereitschaft), ReadinessBadge von Emoji auf CSS-Punkte umgestellt, größere Touch-Ziele im Anpassen-Modus, Kiosk-Look im Status-Monitor (Chrome ausgeblendet), "zuletzt aktualisiert"-Anzeige.

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 12:29:54 +02:00
co-authored by Claude Sonnet 5
parent 5fc8079df6
commit a46897570d
15 changed files with 1094 additions and 59 deletions
@@ -1,3 +1,5 @@
import { Badge } from "../ui/badge";
export type Readiness = "ready" | "limited" | "not-ready" | "unknown";
const LABEL: Record<Readiness, string> = {
@@ -7,18 +9,18 @@ const LABEL: Record<Readiness, string> = {
unknown: "Unbekannt",
};
const ICON: Record<Readiness, string> = {
ready: "🟢",
limited: "🟡",
"not-ready": "🔴",
unknown: "",
const VARIANT: Record<Readiness, "success" | "warning" | "danger" | "neutral"> = {
ready: "success",
limited: "warning",
"not-ready": "danger",
unknown: "neutral",
};
const KLASSE: Record<Readiness, string> = {
ready: "badge-ready",
limited: "badge-limited",
"not-ready": "badge-not-ready",
unknown: "badge-unknown",
const PUNKT_FARBE: Record<Readiness, string> = {
ready: "bg-success",
limited: "bg-warning",
"not-ready": "bg-danger",
unknown: "bg-muted-foreground",
};
interface Props {
@@ -29,12 +31,14 @@ interface Props {
/**
* einzige Stelle, die Einsatzbereitschaft farblich/textlich
* darstellt - "unbekannt" (noch nie kontrolliert) sieht bewusst NIE wie
* "einsatzbereit" aus (eigene Farbe/Icon, siehe global.css badge-unknown).
* "einsatzbereit" aus (eigene Farbe, kein Emoji-Icon mehr - konsistenter auf
* Wandmonitoren, wo Emoji-Rendering je nach OS variiert).
*/
export function ReadinessBadge({ status, label }: Props) {
return (
<span className={`badge ${KLASSE[status]}`}>
{ICON[status]} {label ?? LABEL[status]}
</span>
<Badge variant={VARIANT[status]}>
<span className={`h-1.5 w-1.5 rounded-full ${PUNKT_FARBE[status]}`} />
{label ?? LABEL[status]}
</Badge>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { cva, type VariantProps } from "class-variance-authority";
import type { HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
const badgeVariants = cva("inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium", {
variants: {
variant: {
neutral: "bg-surface-sunken text-muted-foreground",
success: "bg-success-bg text-success",
warning: "bg-warning-bg text-warning",
danger: "bg-danger-bg text-danger",
primary: "bg-primary-soft text-primary",
},
},
defaultVariants: {
variant: "neutral",
},
});
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}
+42
View File
@@ -0,0 +1,42 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { forwardRef, type ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-white hover:bg-primary-hover",
secondary: "bg-surface-sunken text-foreground border border-border hover:bg-border/40",
ghost: "hover:bg-surface-sunken text-foreground",
danger: "bg-danger text-white hover:opacity-90",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
}
);
Button.displayName = "Button";
+29
View File
@@ -0,0 +1,29 @@
import { forwardRef, type HTMLAttributes } from "react";
import { cn } from "../../lib/utils";
export const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-xl border border-border bg-surface text-foreground shadow-sm", className)}
{...props}
/>
));
Card.displayName = "Card";
export const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center justify-between gap-2 px-5 pt-4 pb-2", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
export const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-sm font-semibold tracking-wide text-muted-foreground uppercase", className)} {...props} />
)
);
CardTitle.displayName = "CardTitle";
export const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("px-5 pb-5", className)} {...props} />
));
CardContent.displayName = "CardContent";
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+1
View File
@@ -4,6 +4,7 @@ import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles/global.css";
import "./styles/tailwind.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
+83 -22
View File
@@ -2,8 +2,11 @@ import { useCallback, useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
import type { HistorieEintrag, Mangel } from "../../api/types";
import type { DashboardTileId } from "./layout";
import { layoutLaden, layoutSpeichern, STANDARD_REIHENFOLGE } from "./layout";
import { Button } from "../../components/ui/button";
import { cn } from "../../lib/utils";
import type { DashboardTileGroesse, DashboardTileId } from "./layout";
import { flexBasisFuerGroesse, GROESSEN_REIHENFOLGE, layoutLaden, layoutSpeichern, STANDARD_REIHENFOLGE } from "./layout";
import type { TileAkzent } from "./TileFrame";
import { TileFrame } from "./TileFrame";
import { AblaufdatenTile } from "./tiles/AblaufdatenTile";
import { AktivitaetTile } from "./tiles/AktivitaetTile";
@@ -41,6 +44,7 @@ export function DashboardPage() {
const [layout, setLayout] = useState(() => layoutLaden());
const [bearbeitenModus, setBearbeitenModus] = useState(false);
const [monitorModus, setMonitorModus] = useState(false);
const [zuletztAktualisiert, setZuletztAktualisiert] = useState<Date | null>(null);
const ladeDaten = useCallback(() => {
return Promise.all([
@@ -61,6 +65,7 @@ export function DashboardPage() {
setQualifikationsablaeufe(q);
setAktivitaet(h);
setFehler(null);
setZuletztAktualisiert(new Date());
})
.catch(() => setFehler("Dashboard-Daten konnten nicht geladen werden."));
}, []);
@@ -102,8 +107,16 @@ export function DashboardPage() {
layoutSpeichern(neuesLayout);
}
function groesseAendern(id: DashboardTileId) {
const aktuelle = layout.groessen[id] ?? "normal";
const naechsteIndex = (GROESSEN_REIHENFOLGE.indexOf(aktuelle) + 1) % GROESSEN_REIHENFOLGE.length;
const neuesLayout = { ...layout, groessen: { ...layout.groessen, [id]: GROESSEN_REIHENFOLGE[naechsteIndex] } };
setLayout(neuesLayout);
layoutSpeichern(neuesLayout);
}
function layoutZuruecksetzen() {
const neuesLayout = { reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [] };
const neuesLayout: typeof layout = { reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [], groessen: {} };
setLayout(neuesLayout);
layoutSpeichern(neuesLayout);
}
@@ -128,6 +141,25 @@ export function DashboardPage() {
const pruefungenUeberfaellig = prueftermine.filter((p) => p.status === "abgelaufen").length;
const ablaufdatenAbgelaufen = ablaufdaten.filter((a) => a.status === "abgelaufen").length;
function tileAkzent(id: DashboardTileId): TileAkzent {
switch (id) {
case "maengel":
if (maengelKritischAnzahl > 0) return "danger";
if (maengelNeuAnzahl > 0) return "warning";
return "neutral";
case "prueftermine":
return pruefungenUeberfaellig > 0 ? "danger" : "neutral";
case "ablaufdaten":
return ablaufdatenAbgelaufen > 0 ? "danger" : "neutral";
case "einsatzbereitschaft":
if (bereitschaft && bereitschaft.nicht_einsatzbereit > 0) return "danger";
if (bereitschaft && bereitschaft.eingeschraenkt_einsatzbereit > 0) return "warning";
return "neutral";
default:
return "neutral";
}
}
function tileInhalt(id: DashboardTileId) {
switch (id) {
case "aufgaben":
@@ -163,44 +195,73 @@ export function DashboardPage() {
}
return (
<main className="page-wide">
<div className="row-between">
<h1>Dashboard</h1>
<div className="row" style={{ gap: "0.4rem" }}>
{!monitorModus && (
<button className="btn btn-secondary" onClick={() => setBearbeitenModus(!bearbeitenModus)}>
<main className={cn("page-wide", monitorModus && "bg-background min-h-screen")}>
{!monitorModus && (
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
{zuletztAktualisiert && (
<p className="text-xs text-muted-foreground">
Aktualisiert um {zuletztAktualisiert.toLocaleTimeString("de-DE")}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={() => setBearbeitenModus(!bearbeitenModus)}>
{bearbeitenModus ? "Fertig" : "Anpassen"}
</button>
)}
{bearbeitenModus && (
<button className="btn btn-secondary" onClick={layoutZuruecksetzen}>
Zurücksetzen
</button>
)}
<button className="btn btn-secondary" onClick={monitorUmschalten}>
{monitorModus ? "Monitor beenden" : "Status-Monitor"}
</button>
</Button>
{bearbeitenModus && (
<Button variant="secondary" onClick={layoutZuruecksetzen}>
Zurücksetzen
</Button>
)}
<Button variant="secondary" onClick={monitorUmschalten}>
Status-Monitor
</Button>
</div>
</div>
</div>
)}
{monitorModus && (
<Button variant="secondary" size="sm" className="fixed right-3 top-3 z-10 opacity-60" onClick={monitorUmschalten}>
Monitor beenden
</Button>
)}
{fehler && <div className="alert alert-danger">{fehler}</div>}
{laedt && <p className="text-muted">Lade</p>}
{bearbeitenModus && layout.ausgeblendet.length > 0 && (
<div className="mt-3 rounded-md border border-border bg-surface-sunken px-4 py-3 text-sm">
Ausgeblendet:{" "}
{layout.ausgeblendet.map((id, i) => (
<span key={id}>
{i > 0 && ", "}
<button className="text-primary underline underline-offset-2" onClick={() => ausblendenUmschalten(id)}>
{TITEL[id]} wieder einblenden
</button>
</span>
))}
</div>
)}
{!laedt && bereitschaft && (
<div className="card-grid" style={monitorModus ? { fontSize: "1.15rem" } : undefined}>
<div className={cn("card-grid mt-4", monitorModus && "text-lg")}>
{layout.reihenfolge.map((id, index) => {
const ausgeblendet = layout.ausgeblendet.includes(id);
if (ausgeblendet && !bearbeitenModus) return null;
const inhalt = tileInhalt(id);
if (!inhalt) return null;
const groesse: DashboardTileGroesse = layout.groessen[id] ?? (id === "aufgaben" ? "voll" : "normal");
return (
<TileFrame
key={id}
title={TITEL[id]}
vollbreite={id === "aufgaben"}
flexBasis={flexBasisFuerGroesse(groesse)}
groesse={groesse}
akzent={tileAkzent(id)}
bearbeitenAktiv={bearbeitenModus}
ausgeblendet={ausgeblendet}
kannHochVerschieben={index > 0}
kannRunterVerschieben={index < layout.reihenfolge.length - 1}
onAusblendenUmschalten={() => ausblendenUmschalten(id)}
onGroesseAendern={() => groesseAendern(id)}
onHoch={() => tileVerschieben(id, -1)}
onRunter={() => tileVerschieben(id, 1)}
>
+54 -15
View File
@@ -1,13 +1,29 @@
import type { ReactNode } from "react";
import { Button } from "../../components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card";
import { cn } from "../../lib/utils";
import { GROESSE_LABEL, type DashboardTileGroesse } from "./layout";
export type TileAkzent = "neutral" | "warning" | "danger";
const AKZENT_KLASSE: Record<TileAkzent, string> = {
neutral: "border-l-border",
warning: "border-l-warning",
danger: "border-l-danger",
};
interface Props {
title: string;
vollbreite?: boolean;
flexBasis: string;
groesse: DashboardTileGroesse;
akzent?: TileAkzent;
bearbeitenAktiv: boolean;
ausgeblendet: boolean;
kannHochVerschieben: boolean;
kannRunterVerschieben: boolean;
onAusblendenUmschalten: () => void;
onGroesseAendern: () => void;
onHoch: () => void;
onRunter: () => void;
children: ReactNode;
@@ -15,35 +31,58 @@ interface Props {
export function TileFrame({
title,
vollbreite,
flexBasis,
groesse,
akzent = "neutral",
bearbeitenAktiv,
ausgeblendet,
kannHochVerschieben,
kannRunterVerschieben,
onAusblendenUmschalten,
onGroesseAendern,
onHoch,
onRunter,
children,
}: Props) {
return (
<div className="card" style={{ flexBasis: vollbreite ? "100%" : undefined, opacity: ausgeblendet ? 0.5 : 1 }}>
<div className="row-between" style={{ marginBottom: "0.4rem" }}>
<h3 style={{ margin: 0 }}>{title}</h3>
<Card
style={{ flexBasis }}
className={cn("border-l-4 transition-opacity duration-300", AKZENT_KLASSE[akzent], ausgeblendet && "opacity-50")}
>
<CardHeader>
<CardTitle>{title}</CardTitle>
{bearbeitenAktiv && (
<div className="row" style={{ gap: "0.3rem" }}>
<button className="btn btn-secondary" onClick={onHoch} disabled={!kannHochVerschieben} title="Nach oben">
<div className="flex flex-wrap gap-1.5">
<Button
variant="secondary"
size="icon"
className="min-h-11 min-w-11"
onClick={onHoch}
disabled={!kannHochVerschieben}
title="Nach oben"
>
</button>
<button className="btn btn-secondary" onClick={onRunter} disabled={!kannRunterVerschieben} title="Nach unten">
</Button>
<Button
variant="secondary"
size="icon"
className="min-h-11 min-w-11"
onClick={onRunter}
disabled={!kannRunterVerschieben}
title="Nach unten"
>
</button>
<button className="btn btn-secondary" onClick={onAusblendenUmschalten}>
</Button>
<Button variant="secondary" size="sm" className="min-h-11" onClick={onGroesseAendern} title="Größe ändern">
{GROESSE_LABEL[groesse]}
</Button>
<Button variant="secondary" size="sm" className="min-h-11" onClick={onAusblendenUmschalten}>
{ausgeblendet ? "Einblenden" : "Ausblenden"}
</button>
</Button>
</div>
)}
</div>
{!ausgeblendet && children}
</div>
</CardHeader>
{!ausgeblendet && <CardContent>{children}</CardContent>}
</Card>
);
}
+38 -3
View File
@@ -19,9 +19,21 @@ export const STANDARD_REIHENFOLGE: DashboardTileId[] = [
"aktivitaet",
];
export type DashboardTileGroesse = "schmal" | "normal" | "breit" | "voll";
export const GROESSEN_REIHENFOLGE: DashboardTileGroesse[] = ["schmal", "normal", "breit", "voll"];
export const GROESSE_LABEL: Record<DashboardTileGroesse, string> = {
schmal: "Schmal",
normal: "Normal",
breit: "Breit",
voll: "Volle Breite",
};
export interface DashboardLayout {
reihenfolge: DashboardTileId[];
ausgeblendet: DashboardTileId[];
groessen: Partial<Record<DashboardTileId, DashboardTileGroesse>>;
}
const SPEICHER_KEY = "mabea-dashboard-layout";
@@ -30,19 +42,42 @@ function istGueltigeId(wert: unknown): wert is DashboardTileId {
return typeof wert === "string" && (STANDARD_REIHENFOLGE as string[]).includes(wert);
}
function istGueltigeGroesse(wert: unknown): wert is DashboardTileGroesse {
return typeof wert === "string" && (GROESSEN_REIHENFOLGE as string[]).includes(wert);
}
export function layoutLaden(): DashboardLayout {
try {
const roh = localStorage.getItem(SPEICHER_KEY);
if (!roh) return { reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [] };
if (!roh) return { reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [], groessen: {} };
const geparst = JSON.parse(roh);
const gespeicherteReihenfolge = Array.isArray(geparst.reihenfolge)
? geparst.reihenfolge.filter(istGueltigeId)
: [];
const fehlende = STANDARD_REIHENFOLGE.filter((id) => !gespeicherteReihenfolge.includes(id));
const ausgeblendet = Array.isArray(geparst.ausgeblendet) ? geparst.ausgeblendet.filter(istGueltigeId) : [];
return { reihenfolge: [...gespeicherteReihenfolge, ...fehlende], ausgeblendet };
const groessen: Partial<Record<DashboardTileId, DashboardTileGroesse>> = {};
if (geparst.groessen && typeof geparst.groessen === "object") {
for (const [id, groesse] of Object.entries(geparst.groessen)) {
if (istGueltigeId(id) && istGueltigeGroesse(groesse)) groessen[id] = groesse;
}
}
return { reihenfolge: [...gespeicherteReihenfolge, ...fehlende], ausgeblendet, groessen };
} catch {
return { reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [] };
return { reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [], groessen: {} };
}
}
export function flexBasisFuerGroesse(groesse: DashboardTileGroesse): string {
switch (groesse) {
case "schmal":
return "14rem";
case "breit":
return "28rem";
case "voll":
return "100%";
default:
return "18rem";
}
}
+23
View File
@@ -0,0 +1,23 @@
/* Nur Theme+Utilities, kein Preflight - global.css bleibt für den Rest der App
die Basis, Tailwind wird gezielt fürs Dashboard-Redesign genutzt. */
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
@theme {
--color-background: var(--color-bg);
--color-surface: var(--color-surface);
--color-surface-sunken: var(--color-surface-sunken);
--color-border: var(--color-border);
--color-border-strong: var(--color-border-strong);
--color-foreground: var(--color-text);
--color-muted-foreground: var(--color-text-muted);
--color-primary: var(--color-primary);
--color-primary-hover: var(--color-primary-hover);
--color-primary-soft: var(--color-primary-soft);
--color-danger: var(--color-danger);
--color-danger-bg: var(--color-danger-bg);
--color-warning: var(--color-warning);
--color-warning-bg: var(--color-warning-bg);
--color-success: var(--color-success);
--color-success-bg: var(--color-success-bg);
}