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:
@@ -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 ++++++++-
|
||||
|
||||
---
|
||||
|
||||
Generated
+88
-57
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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