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
+13
View File
@@ -6573,3 +6573,16 @@ Keine Commits in dieser Session.
- frontend/src/pages/dashboard/tiles/QualifikationTile.tsx | 6 ++-
---
## 2026-09-07 13:33 13:35 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- 85df756 fix(frontend): Tailwind-Utilities griffen nicht - global.css war unlayered
### Geänderte Dateien
- DEVLOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++
- frontend/src/main.tsx | 1 -
- frontend/src/styles/tailwind.css | 9 ++++++++-
---
+88 -57
View File
@@ -8,9 +8,6 @@
"name": "mabea-frontend",
"version": "0.1.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-slot": "^1.3.3",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1",
@@ -19,6 +16,7 @@
"lucide-react": "^1.42.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-grid-layout": "^2.2.4",
"react-router-dom": "^6.26.2",
"tailwind-merge": "^3.6.0"
},
@@ -1625,59 +1623,6 @@
"node": ">=6.9.0"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -3950,6 +3895,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -5324,6 +5275,15 @@
"node": ">=18"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -5539,6 +5499,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -5564,6 +5535,44 @@
"react": "^18.3.1"
}
},
"node_modules/react-draggable": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz",
"integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-grid-layout": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.4.tgz",
"integrity": "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.6",
"react-resizable": "^3.1.3",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5574,6 +5583,20 @@
"node": ">=0.10.0"
}
},
"node_modules/react-resizable": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
"integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
"license": "MIT",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.5.0"
},
"peerDependencies": {
"react": ">= 16.3",
"react-dom": ">= 16.3"
}
},
"node_modules/react-router": {
"version": "6.30.6",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
@@ -5718,6 +5741,12 @@
"node": ">=0.10.0"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -6332,7 +6361,9 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/type-fest": {
"version": "0.16.0",
+1 -3
View File
@@ -11,9 +11,6 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@radix-ui/react-slot": "^1.3.3",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1",
@@ -22,6 +19,7 @@
"lucide-react": "^1.42.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-grid-layout": "^2.2.4",
"react-router-dom": "^6.26.2",
"tailwind-merge": "^3.6.0"
},
+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: [] };
}
}