feat(dashboard): echtes Drag & Drop statt Pfeil-Buttons, moderneres Kachel-Design
CI / backend-tests (push) Failing after 2m5s
CI / frontend-build (push) Successful in 20s

@dnd-kit statt ↑/↓-Buttons zum Anordnen (Griff-Icon, Tastatur-Fallback inklusive). Card-Komponente auf kleineren Radius umgestellt (weniger "Bubble"-Look). Kennzahlen/Mängel-Kachel zeigen jetzt eine große, fette Kopfzahl mit kleinem Label statt reiner Text-Liste - Inspiration: Resgrid Big Board (Statusfarben statt Deko) und moderne SaaS-Dashboards (Linear/Vercel: große Zahlen, dünne Border, wenig Schatten).

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:42:33 +02:00
co-authored by Claude Sonnet 5
parent a46897570d
commit c18638a169
8 changed files with 233 additions and 84 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ 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)}
className={cn("rounded-lg border border-border bg-surface text-foreground shadow-sm", className)}
{...props}
/>
));
+44 -35
View File
@@ -1,3 +1,6 @@
import type { DragEndEvent } from "@dnd-kit/core";
import { DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
import { arrayMove, rectSortingStrategy, SortableContext, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { useCallback, useEffect, useState } from "react";
import { apiRequest } from "../../api/client";
@@ -86,13 +89,18 @@ export function DashboardPage() {
return () => document.removeEventListener("fullscreenchange", fullscreenGeaendert);
}, []);
function tileVerschieben(id: DashboardTileId, richtung: -1 | 1) {
const index = layout.reihenfolge.indexOf(id);
const zielIndex = index + richtung;
if (zielIndex < 0 || zielIndex >= layout.reihenfolge.length) return;
const neueReihenfolge = [...layout.reihenfolge];
[neueReihenfolge[index], neueReihenfolge[zielIndex]] = [neueReihenfolge[zielIndex], neueReihenfolge[index]];
const neuesLayout = { ...layout, reihenfolge: neueReihenfolge };
const sensoren = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
function dragEnde(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const von = layout.reihenfolge.indexOf(active.id as DashboardTileId);
const nach = layout.reihenfolge.indexOf(over.id as DashboardTileId);
if (von === -1 || nach === -1) return;
const neuesLayout = { ...layout, reihenfolge: arrayMove(layout.reihenfolge, von, nach) };
setLayout(neuesLayout);
layoutSpeichern(neuesLayout);
}
@@ -242,34 +250,35 @@ export function DashboardPage() {
</div>
)}
{!laedt && bereitschaft && (
<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]}
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)}
>
{inhalt}
</TileFrame>
);
})}
</div>
<DndContext sensors={sensoren} onDragEnd={dragEnde}>
<SortableContext items={layout.reihenfolge} strategy={rectSortingStrategy}>
<div className={cn("card-grid mt-4", monitorModus && "text-lg")}>
{layout.reihenfolge.map((id) => {
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}
id={id}
title={TITEL[id]}
flexBasis={flexBasisFuerGroesse(groesse)}
groesse={groesse}
akzent={tileAkzent(id)}
bearbeitenAktiv={bearbeitenModus}
ausgeblendet={ausgeblendet}
onAusblendenUmschalten={() => ausblendenUmschalten(id)}
onGroesseAendern={() => groesseAendern(id)}
>
{inhalt}
</TileFrame>
);
})}
</div>
</SortableContext>
</DndContext>
)}
</main>
);
+32 -32
View File
@@ -1,9 +1,11 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
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";
import { GROESSE_LABEL, type DashboardTileGroesse, type DashboardTileId } from "./layout";
export type TileAkzent = "neutral" | "warning" | "danger";
@@ -14,65 +16,63 @@ const AKZENT_KLASSE: Record<TileAkzent, string> = {
};
interface Props {
id: DashboardTileId;
title: string;
flexBasis: string;
groesse: DashboardTileGroesse;
akzent?: TileAkzent;
bearbeitenAktiv: boolean;
ausgeblendet: boolean;
kannHochVerschieben: boolean;
kannRunterVerschieben: boolean;
onAusblendenUmschalten: () => void;
onGroesseAendern: () => void;
onHoch: () => void;
onRunter: () => void;
children: ReactNode;
}
export function TileFrame({
id,
title,
flexBasis,
groesse,
akzent = "neutral",
bearbeitenAktiv,
ausgeblendet,
kannHochVerschieben,
kannRunterVerschieben,
onAusblendenUmschalten,
onGroesseAendern,
onHoch,
onRunter,
children,
}: Props) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
disabled: !bearbeitenAktiv,
});
return (
<Card
style={{ flexBasis }}
className={cn("border-l-4 transition-opacity duration-300", AKZENT_KLASSE[akzent], ausgeblendet && "opacity-50")}
ref={setNodeRef}
style={{ flexBasis, transform: CSS.Transform.toString(transform), transition }}
className={cn(
"border-l-4 transition-opacity duration-300",
AKZENT_KLASSE[akzent],
ausgeblendet && "opacity-50",
isDragging && "z-10 shadow-lg"
)}
>
<CardHeader>
<CardTitle>{title}</CardTitle>
<div className="flex items-center gap-2">
{bearbeitenAktiv && (
<button
{...attributes}
{...listeners}
className="min-h-11 min-w-11 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
title="Ziehen zum Anordnen"
aria-label={`${title} verschieben`}
>
</button>
)}
<CardTitle>{title}</CardTitle>
</div>
{bearbeitenAktiv && (
<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
variant="secondary"
size="icon"
className="min-h-11 min-w-11"
onClick={onRunter}
disabled={!kannRunterVerschieben}
title="Nach unten"
>
</Button>
<Button variant="secondary" size="sm" className="min-h-11" onClick={onGroesseAendern} title="Größe ändern">
{GROESSE_LABEL[groesse]}
</Button>
@@ -6,14 +6,17 @@ interface Props {
export function KennzahlenTile({ kennzahlen }: Props) {
return (
<ul className="card-list">
<li style={{ padding: "0.3rem 0" }}>Offene Fehlbestände: {kennzahlen.anzahl_offener_fehlbestaende}</li>
<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>
</ul>
<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-muted-foreground">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>
</ul>
</div>
);
}
@@ -15,16 +15,13 @@ export function MaengelTile({ maengel, maengelNeuAnzahl, maengelKritischAnzahl }
return (
<>
<p className="stat-number">{maengelOffenListe.length} offen</p>
<p className="text-4xl font-bold tabular-nums leading-none">{maengelOffenListe.length}</p>
<p className="mb-2 text-xs uppercase tracking-wide text-muted-foreground">Offen</p>
{maengelNeuAnzahl > 0 && (
<p className="text-muted" style={{ color: "var(--color-warning)", fontWeight: 600 }}>
{maengelNeuAnzahl} neu gemeldet
</p>
<p className="font-semibold text-warning">{maengelNeuAnzahl} neu gemeldet</p>
)}
{maengelKritischAnzahl > 0 && (
<p className="text-muted" style={{ color: "var(--color-danger)" }}>
{maengelKritischAnzahl} kritisch (blockiert Einsatzbereitschaft)
</p>
<p className="text-danger">{maengelKritischAnzahl} kritisch (blockiert Einsatzbereitschaft)</p>
)}
{maengelOffenListe.length > 0 && (
<>