feat(dashboard): echtes freies Grid-Layout mit Resize statt Sortable+Größenstufen
@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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user