feat(dashboard): Tailwind CSS + shadcn/ui eingeführt, Dashboard modernisiert
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:
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user