feat(dashboard): echtes freies Grid-Layout mit Resize statt Sortable+Größenstufen
CI / backend-tests (push) Failing after 2m4s
CI / frontend-build (push) Successful in 20s

@dnd-kit (nur Reihenfolge tauschen) durch react-grid-layout ersetzt - Kacheln lassen sich jetzt frei auf einem 12-Spalten-Raster positionieren (nicht nur umsortieren) UND per Ecke ziehen dynamisch in der Größe ändern (nicht nur zwischen 4 festen Stufen wechseln). Layout-Modell in layout.ts auf x/y/w/h pro Kachel umgestellt (vorher Reihenfolge-Array + Größen-Enum). TileFrame vereinfacht (kein Drag-Griff/Größen-Button mehr nötig, das übernimmt die Grid-Lib direkt an der Kachel).

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 13:41:23 +02:00
co-authored by Claude Sonnet 5
parent 85df756c3f
commit 74ed250ad7
6 changed files with 228 additions and 231 deletions
+58 -61
View File
@@ -1,14 +1,14 @@
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 { GridLayout, useContainerWidth } from "react-grid-layout";
import type { Layout } from "react-grid-layout";
import "react-grid-layout/css/styles.css";
import { useCallback, useEffect, useMemo, useState } from "react";
import { apiRequest } from "../../api/client";
import type { HistorieEintrag, Mangel } from "../../api/types";
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 { DashboardTileId } from "./layout";
import { ALLE_TILE_IDS, GRID_SPALTEN, GRID_ZEILENHOEHE, layoutLaden, layoutSpeichern, STANDARD_RASTER } from "./layout";
import type { TileAkzent } from "./TileFrame";
import { TileFrame } from "./TileFrame";
import { AblaufdatenTile } from "./tiles/AblaufdatenTile";
@@ -48,6 +48,7 @@ export function DashboardPage() {
const [bearbeitenModus, setBearbeitenModus] = useState(false);
const [monitorModus, setMonitorModus] = useState(false);
const [zuletztAktualisiert, setZuletztAktualisiert] = useState<Date | null>(null);
const { width: gridBreite, containerRef, mounted } = useContainerWidth();
const ladeDaten = useCallback(() => {
return Promise.all([
@@ -89,11 +90,6 @@ export function DashboardPage() {
return () => document.removeEventListener("fullscreenchange", fullscreenGeaendert);
}, []);
const sensoren = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
const aktualisiereLayout = useCallback((aendern: (bisherig: typeof layout) => typeof layout) => {
setLayout((bisherig) => {
const neuesLayout = aendern(bisherig);
@@ -102,15 +98,15 @@ export function DashboardPage() {
});
}, []);
const dragEnde = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const rasterGeaendert = useCallback(
(neuesRaster: Layout) => {
aktualisiereLayout((bisherig) => {
const von = bisherig.reihenfolge.indexOf(active.id as DashboardTileId);
const nach = bisherig.reihenfolge.indexOf(over.id as DashboardTileId);
if (von === -1 || nach === -1) return bisherig;
return { ...bisherig, reihenfolge: arrayMove(bisherig.reihenfolge, von, nach) };
const raster = { ...bisherig.raster };
for (const item of neuesRaster) {
const id = item.i as DashboardTileId;
raster[id] = { x: item.x, y: item.y, w: item.w, h: item.h };
}
return { ...bisherig, raster };
});
},
[aktualisiereLayout]
@@ -129,19 +125,8 @@ export function DashboardPage() {
[aktualisiereLayout]
);
const groesseAendern = useCallback(
(id: DashboardTileId) => {
aktualisiereLayout((bisherig) => {
const aktuelle = bisherig.groessen[id] ?? "normal";
const naechsteIndex = (GROESSEN_REIHENFOLGE.indexOf(aktuelle) + 1) % GROESSEN_REIHENFOLGE.length;
return { ...bisherig, groessen: { ...bisherig.groessen, [id]: GROESSEN_REIHENFOLGE[naechsteIndex] } };
});
},
[aktualisiereLayout]
);
const layoutZuruecksetzen = useCallback(() => {
aktualisiereLayout(() => ({ reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [], groessen: {} }));
aktualisiereLayout(() => ({ raster: { ...STANDARD_RASTER }, ausgeblendet: [] }));
}, [aktualisiereLayout]);
async function monitorUmschalten() {
@@ -217,6 +202,13 @@ export function DashboardPage() {
}
}
const sichtbareIds = ALLE_TILE_IDS.filter((id) => bearbeitenModus || !layout.ausgeblendet.includes(id));
const rasterLayout: Layout = useMemo(
() => sichtbareIds.map((id) => ({ i: id, ...layout.raster[id], minW: 2, minH: 2 })),
[sichtbareIds, layout.raster]
);
return (
<main className={cn("page-wide", monitorModus && "bg-background min-h-screen")}>
{!monitorModus && (
@@ -271,37 +263,42 @@ export function DashboardPage() {
))}
</div>
)}
{!laedt && bereitschaft && (
<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>
{bearbeitenModus && (
<p className="mt-2 text-xs text-muted-foreground">
Kachel ziehen zum Verschieben, an der unteren rechten Ecke ziehen zum Vergrößern/Verkleinern.
</p>
)}
<div ref={containerRef as React.RefObject<HTMLDivElement>} className="mt-4">
{!laedt && bereitschaft && mounted && (
<GridLayout
width={gridBreite}
layout={rasterLayout}
gridConfig={{ cols: GRID_SPALTEN, rowHeight: GRID_ZEILENHOEHE, margin: [16, 16] }}
dragConfig={{ enabled: bearbeitenModus, cancel: "button" }}
resizeConfig={{ enabled: bearbeitenModus }}
onLayoutChange={rasterGeaendert}
autoSize
>
{sichtbareIds.map((id) => {
const inhalt = tileInhalt(id);
if (!inhalt) return null;
const ausgeblendet = layout.ausgeblendet.includes(id);
return (
<TileFrame
key={id}
title={TITEL[id]}
akzent={tileAkzent(id)}
bearbeitenAktiv={bearbeitenModus}
ausgeblendet={ausgeblendet}
onAusblendenUmschalten={() => ausblendenUmschalten(id)}
>
{inhalt}
</TileFrame>
);
})}
</GridLayout>
)}
</div>
</main>
);
}
+33 -71
View File
@@ -1,12 +1,8 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { memo } from "react";
import type { ReactNode } from "react";
import { forwardRef, memo, type HTMLAttributes, 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, type DashboardTileId } from "./layout";
export type TileAkzent = "neutral" | "warning" | "danger";
@@ -16,84 +12,50 @@ const AKZENT_KLASSE: Record<TileAkzent, string> = {
danger: "border-l-danger",
};
interface Props {
id: DashboardTileId;
interface Props extends HTMLAttributes<HTMLDivElement> {
title: string;
flexBasis: string;
groesse: DashboardTileGroesse;
akzent?: TileAkzent;
bearbeitenAktiv: boolean;
ausgeblendet: boolean;
onAusblendenUmschalten: (id: DashboardTileId) => void;
onGroesseAendern: (id: DashboardTileId) => void;
onAusblendenUmschalten: () => void;
children: ReactNode;
}
function TileFrameKomponente({
id,
title,
flexBasis,
groesse,
akzent = "neutral",
bearbeitenAktiv,
ausgeblendet,
onAusblendenUmschalten,
onGroesseAendern,
children,
}: Props) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
disabled: !bearbeitenAktiv,
});
return (
<Card
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>
<div className="flex items-center gap-2">
{bearbeitenAktiv && (
<button
{...attributes}
{...listeners}
type="button"
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`}
aria-roledescription="Kachel-Griff, mit Pfeiltasten verschiebbar"
>
</button>
)}
// forwardRef + generische DOM-Props nötig, react-grid-layout reicht Style/Events
// (Drag/Resize-Handler) direkt an die Kachel-Wurzel durch.
export const TileFrame = memo(
forwardRef<HTMLDivElement, Props>(function TileFrame(
{ title, akzent = "neutral", bearbeitenAktiv, ausgeblendet, onAusblendenUmschalten, children, className, ...rest },
ref
) {
return (
<Card
ref={ref}
className={cn(
"flex h-full flex-col overflow-hidden border-l-4 transition-opacity duration-300",
AKZENT_KLASSE[akzent],
ausgeblendet && "opacity-50",
bearbeitenAktiv && "cursor-move",
className
)}
{...rest}
>
<CardHeader className="shrink-0">
<CardTitle>{title}</CardTitle>
</div>
{bearbeitenAktiv && (
<div className="flex flex-wrap gap-1.5">
{bearbeitenAktiv && (
<Button
variant="secondary"
size="sm"
className="min-h-11"
onClick={() => onGroesseAendern(id)}
title="Größe ändern"
onPointerDown={(e) => e.stopPropagation()}
onClick={onAusblendenUmschalten}
>
{GROESSE_LABEL[groesse]}
</Button>
<Button variant="secondary" size="sm" className="min-h-11" onClick={() => onAusblendenUmschalten(id)}>
{ausgeblendet ? "Einblenden" : "Ausblenden"}
</Button>
</div>
)}
</CardHeader>
{!ausgeblendet && <CardContent>{children}</CardContent>}
</Card>
);
}
export const TileFrame = memo(TileFrameKomponente);
)}
</CardHeader>
{!ausgeblendet && <CardContent className="min-h-0 flex-1 overflow-auto">{children}</CardContent>}
</Card>
);
})
);
+35 -39
View File
@@ -8,7 +8,7 @@ export type DashboardTileId =
| "qualifikation"
| "aktivitaet";
export const STANDARD_REIHENFOLGE: DashboardTileId[] = [
export const ALLE_TILE_IDS: DashboardTileId[] = [
"aufgaben",
"einsatzbereitschaft",
"prueftermine",
@@ -19,65 +19,61 @@ export const STANDARD_REIHENFOLGE: DashboardTileId[] = [
"aktivitaet",
];
export type DashboardTileGroesse = "schmal" | "normal" | "breit" | "voll";
export const GRID_SPALTEN = 12;
export const GRID_ZEILENHOEHE = 56;
export const GROESSEN_REIHENFOLGE: DashboardTileGroesse[] = ["schmal", "normal", "breit", "voll"];
export interface RasterPosition {
x: number;
y: number;
w: number;
h: number;
}
export const GROESSE_LABEL: Record<DashboardTileGroesse, string> = {
schmal: "Schmal",
normal: "Normal",
breit: "Breit",
voll: "Volle Breite",
// Freie Grid-Positionen als Startzustand - grob nach Wichtigkeit sortiert,
// Aufgaben-Kachel volle Breite oben.
export const STANDARD_RASTER: Record<DashboardTileId, RasterPosition> = {
aufgaben: { x: 0, y: 0, w: 12, h: 3 },
einsatzbereitschaft: { x: 0, y: 3, w: 4, h: 5 },
prueftermine: { x: 4, y: 3, w: 4, h: 5 },
ablaufdaten: { x: 8, y: 3, w: 4, h: 5 },
kennzahlen: { x: 0, y: 8, w: 4, h: 5 },
maengel: { x: 4, y: 8, w: 4, h: 5 },
qualifikation: { x: 8, y: 8, w: 4, h: 4 },
aktivitaet: { x: 0, y: 13, w: 8, h: 5 },
};
export interface DashboardLayout {
reihenfolge: DashboardTileId[];
raster: Record<DashboardTileId, RasterPosition>;
ausgeblendet: DashboardTileId[];
groessen: Partial<Record<DashboardTileId, DashboardTileGroesse>>;
}
const SPEICHER_KEY = "mabea-dashboard-layout";
const SPEICHER_KEY = "mabea-dashboard-layout-v2";
function istGueltigeId(wert: unknown): wert is DashboardTileId {
return typeof wert === "string" && (STANDARD_REIHENFOLGE as string[]).includes(wert);
return typeof wert === "string" && (ALLE_TILE_IDS as string[]).includes(wert);
}
function istGueltigeGroesse(wert: unknown): wert is DashboardTileGroesse {
return typeof wert === "string" && (GROESSEN_REIHENFOLGE as string[]).includes(wert);
function istGueltigePosition(wert: unknown): wert is RasterPosition {
if (!wert || typeof wert !== "object") return false;
const p = wert as Record<string, unknown>;
return typeof p.x === "number" && typeof p.y === "number" && typeof p.w === "number" && typeof p.h === "number";
}
export function layoutLaden(): DashboardLayout {
try {
const roh = localStorage.getItem(SPEICHER_KEY);
if (!roh) return { reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [], groessen: {} };
if (!roh) return { raster: { ...STANDARD_RASTER }, ausgeblendet: [] };
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) : [];
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;
const raster: Record<DashboardTileId, RasterPosition> = { ...STANDARD_RASTER };
if (geparst.raster && typeof geparst.raster === "object") {
for (const [id, position] of Object.entries(geparst.raster)) {
if (istGueltigeId(id) && istGueltigePosition(position)) raster[id] = position;
}
}
return { reihenfolge: [...gespeicherteReihenfolge, ...fehlende], ausgeblendet, groessen };
const ausgeblendet = Array.isArray(geparst.ausgeblendet) ? geparst.ausgeblendet.filter(istGueltigeId) : [];
return { raster, ausgeblendet };
} catch {
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";
return { raster: { ...STANDARD_RASTER }, ausgeblendet: [] };
}
}