perf(dashboard): React-Review - Memoization, stabile Callbacks, Key-Fixes
CI / backend-tests (push) Failing after 2m4s
CI / frontend-build (push) Successful in 20s

React.memo auf allen 8 Kachel-Komponenten + TileFrame, damit Interaktionen (Anpassen/Monitor/Größe/Ausblenden) nicht mehr alle Kacheln unnötig neu rendern. Voraussetzung dafür: onAusblendenUmschalten/onGroesseAendern als stabile useCallback-Referenzen mit id-Parameter statt pro Kachel neu erzeugter Closures. Die vier Layout-Mutationsfunktionen (dragEnde/ausblendenUmschalten/groesseAendern/layoutZuruecksetzen) über eine gemeinsame aktualisiereLayout-Helper-Funktion mit funktionalem setState dedupliziert (vermeidet stale-closure-Risiko bei schnellen Interaktionen). Instabile Index-Keys in PrueftermineTile/AufgabenTile durch stabile Keys ersetzt. maengelOffenListe in MaengelTile mit useMemo. Fehler-Anzeige bekommt "Erneut versuchen"-Button. Drag-Griff in TileFrame: type="button" + aria-roledescription für Screenreader.

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:27:36 +02:00
co-authored by Claude Sonnet 5
parent 726d6e29b1
commit 0c448c7ff7
11 changed files with 223 additions and 52 deletions
+114
View File
@@ -6417,3 +6417,117 @@ Keine Commits in dieser Session.
- frontend/vite.config.ts | 2 + - frontend/vite.config.ts | 2 +
--- ---
## 2026-09-07 12:40 12:42 (2m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
- c18638a feat(dashboard): echtes Drag & Drop statt Pfeil-Buttons, moderneres Kachel-Design
### Geänderte Dateien
- DEVLOG.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/package-lock.json | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/package.json | 3 +++
- frontend/src/components/ui/card.tsx | 2 +-
- frontend/src/pages/dashboard/DashboardPage.tsx | 79 ++++++++++++++++++++++++++++++++++++-----------------------------
- frontend/src/pages/dashboard/TileFrame.tsx | 64 ++++++++++++++++++++++++++--------------------------
- frontend/src/pages/dashboard/tiles/KennzahlenTile.tsx | 21 ++++++++++--------
- frontend/src/pages/dashboard/tiles/MaengelTile.tsx | 11 ++++-----
---
## 2026-09-07 13:16 13:17 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/package-lock.json | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/package.json | 3 +++
- frontend/src/components/ui/card.tsx | 2 +-
- frontend/src/pages/dashboard/DashboardPage.tsx | 79 ++++++++++++++++++++++++++++++++++++-----------------------------
- frontend/src/pages/dashboard/TileFrame.tsx | 64 ++++++++++++++++++++++++++--------------------------
- frontend/src/pages/dashboard/tiles/KennzahlenTile.tsx | 21 ++++++++++--------
- frontend/src/pages/dashboard/tiles/MaengelTile.tsx | 11 ++++-----
---
## 2026-09-07 13:19 13:20 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- DEVLOG.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/package-lock.json | 62 +++++++++++++++++++++++++++++++++++++++++++++++++++
- frontend/package.json | 3 +++
- frontend/src/components/ui/card.tsx | 2 +-
- frontend/src/pages/dashboard/DashboardPage.tsx | 79 ++++++++++++++++++++++++++++++++++++-----------------------------
- frontend/src/pages/dashboard/TileFrame.tsx | 64 ++++++++++++++++++++++++++--------------------------
- frontend/src/pages/dashboard/tiles/KennzahlenTile.tsx | 21 ++++++++++--------
- frontend/src/pages/dashboard/tiles/MaengelTile.tsx | 11 ++++-----
---
## 2026-09-07 13:21 13:22 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** agents
### Commits
- 726d6e2 chore(agents): react/tailwind/css/html-expert Subagenten aus 0xfurai/claude-code-subagents geladen
### Geänderte Dateien
- .claude/agents/DEVLOG.md | 20 ++++++++++++++++++++
- .claude/agents/css-expert.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
- .claude/agents/html-expert.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- .claude/agents/react-expert.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- .claude/agents/tailwind-expert.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-07 13:23 13:24 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .claude/agents/DEVLOG.md | 20 ++++++++++++++++++++
- .claude/agents/css-expert.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
- .claude/agents/html-expert.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- .claude/agents/react-expert.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- .claude/agents/tailwind-expert.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-07 13:24 13:24 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .claude/agents/DEVLOG.md | 20 ++++++++++++++++++++
- .claude/agents/css-expert.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
- .claude/agents/html-expert.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- .claude/agents/react-expert.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- .claude/agents/tailwind-expert.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
---
## 2026-09-07 13:25 13:25 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** asb-material
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .claude/agents/DEVLOG.md | 20 ++++++++++++++++++++
- .claude/agents/css-expert.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
- .claude/agents/html-expert.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- .claude/agents/react-expert.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- .claude/agents/tailwind-expert.md | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
---
+50 -28
View File
@@ -94,40 +94,55 @@ export function DashboardPage() {
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
); );
function dragEnde(event: DragEndEvent) { const aktualisiereLayout = useCallback((aendern: (bisherig: typeof layout) => typeof layout) => {
setLayout((bisherig) => {
const neuesLayout = aendern(bisherig);
layoutSpeichern(neuesLayout);
return neuesLayout;
});
}, []);
const dragEnde = useCallback(
(event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return;
const von = layout.reihenfolge.indexOf(active.id as DashboardTileId); aktualisiereLayout((bisherig) => {
const nach = layout.reihenfolge.indexOf(over.id as DashboardTileId); const von = bisherig.reihenfolge.indexOf(active.id as DashboardTileId);
if (von === -1 || nach === -1) return; const nach = bisherig.reihenfolge.indexOf(over.id as DashboardTileId);
const neuesLayout = { ...layout, reihenfolge: arrayMove(layout.reihenfolge, von, nach) }; if (von === -1 || nach === -1) return bisherig;
setLayout(neuesLayout); return { ...bisherig, reihenfolge: arrayMove(bisherig.reihenfolge, von, nach) };
layoutSpeichern(neuesLayout); });
} },
[aktualisiereLayout]
);
function ausblendenUmschalten(id: DashboardTileId) { const ausblendenUmschalten = useCallback(
const istAusgeblendet = layout.ausgeblendet.includes(id); (id: DashboardTileId) => {
const neuesLayout = { aktualisiereLayout((bisherig) => {
...layout, const istAusgeblendet = bisherig.ausgeblendet.includes(id);
ausgeblendet: istAusgeblendet ? layout.ausgeblendet.filter((x) => x !== id) : [...layout.ausgeblendet, id], return {
...bisherig,
ausgeblendet: istAusgeblendet ? bisherig.ausgeblendet.filter((x) => x !== id) : [...bisherig.ausgeblendet, id],
}; };
setLayout(neuesLayout); });
layoutSpeichern(neuesLayout); },
} [aktualisiereLayout]
);
function groesseAendern(id: DashboardTileId) { const groesseAendern = useCallback(
const aktuelle = layout.groessen[id] ?? "normal"; (id: DashboardTileId) => {
aktualisiereLayout((bisherig) => {
const aktuelle = bisherig.groessen[id] ?? "normal";
const naechsteIndex = (GROESSEN_REIHENFOLGE.indexOf(aktuelle) + 1) % GROESSEN_REIHENFOLGE.length; const naechsteIndex = (GROESSEN_REIHENFOLGE.indexOf(aktuelle) + 1) % GROESSEN_REIHENFOLGE.length;
const neuesLayout = { ...layout, groessen: { ...layout.groessen, [id]: GROESSEN_REIHENFOLGE[naechsteIndex] } }; return { ...bisherig, groessen: { ...bisherig.groessen, [id]: GROESSEN_REIHENFOLGE[naechsteIndex] } };
setLayout(neuesLayout); });
layoutSpeichern(neuesLayout); },
} [aktualisiereLayout]
);
function layoutZuruecksetzen() { const layoutZuruecksetzen = useCallback(() => {
const neuesLayout: typeof layout = { reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [], groessen: {} }; aktualisiereLayout(() => ({ reihenfolge: STANDARD_REIHENFOLGE, ausgeblendet: [], groessen: {} }));
setLayout(neuesLayout); }, [aktualisiereLayout]);
layoutSpeichern(neuesLayout);
}
async function monitorUmschalten() { async function monitorUmschalten() {
if (monitorModus) { if (monitorModus) {
@@ -234,7 +249,14 @@ export function DashboardPage() {
Monitor beenden Monitor beenden
</Button> </Button>
)} )}
{fehler && <div className="alert alert-danger">{fehler}</div>} {fehler && (
<div className="alert alert-danger flex flex-wrap items-center justify-between gap-2">
<span>{fehler}</span>
<Button variant="secondary" size="sm" onClick={ladeDaten}>
Erneut versuchen
</Button>
</div>
)}
{laedt && <p className="text-muted">Lade</p>} {laedt && <p className="text-muted">Lade</p>}
{bearbeitenModus && layout.ausgeblendet.length > 0 && ( {bearbeitenModus && layout.ausgeblendet.length > 0 && (
<div className="mt-3 rounded-md border border-border bg-surface-sunken px-4 py-3 text-sm"> <div className="mt-3 rounded-md border border-border bg-surface-sunken px-4 py-3 text-sm">
+16 -5
View File
@@ -1,5 +1,6 @@
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { memo } from "react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
@@ -23,12 +24,12 @@ interface Props {
akzent?: TileAkzent; akzent?: TileAkzent;
bearbeitenAktiv: boolean; bearbeitenAktiv: boolean;
ausgeblendet: boolean; ausgeblendet: boolean;
onAusblendenUmschalten: () => void; onAusblendenUmschalten: (id: DashboardTileId) => void;
onGroesseAendern: () => void; onGroesseAendern: (id: DashboardTileId) => void;
children: ReactNode; children: ReactNode;
} }
export function TileFrame({ function TileFrameKomponente({
id, id,
title, title,
flexBasis, flexBasis,
@@ -62,9 +63,11 @@ export function TileFrame({
<button <button
{...attributes} {...attributes}
{...listeners} {...listeners}
type="button"
className="min-h-11 min-w-11 cursor-grab touch-none text-muted-foreground active:cursor-grabbing" className="min-h-11 min-w-11 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
title="Ziehen zum Anordnen" title="Ziehen zum Anordnen"
aria-label={`${title} verschieben`} aria-label={`${title} verschieben`}
aria-roledescription="Kachel-Griff, mit Pfeiltasten verschiebbar"
> >
</button> </button>
@@ -73,10 +76,16 @@ export function TileFrame({
</div> </div>
{bearbeitenAktiv && ( {bearbeitenAktiv && (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<Button variant="secondary" size="sm" className="min-h-11" onClick={onGroesseAendern} title="Größe ändern"> <Button
variant="secondary"
size="sm"
className="min-h-11"
onClick={() => onGroesseAendern(id)}
title="Größe ändern"
>
{GROESSE_LABEL[groesse]} {GROESSE_LABEL[groesse]}
</Button> </Button>
<Button variant="secondary" size="sm" className="min-h-11" onClick={onAusblendenUmschalten}> <Button variant="secondary" size="sm" className="min-h-11" onClick={() => onAusblendenUmschalten(id)}>
{ausgeblendet ? "Einblenden" : "Ausblenden"} {ausgeblendet ? "Einblenden" : "Ausblenden"}
</Button> </Button>
</div> </div>
@@ -86,3 +95,5 @@ export function TileFrame({
</Card> </Card>
); );
} }
export const TileFrame = memo(TileFrameKomponente);
@@ -1,3 +1,4 @@
import { memo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { InventoryStatus } from "../../../components/status/InventoryStatus"; import { InventoryStatus } from "../../../components/status/InventoryStatus";
@@ -7,7 +8,7 @@ interface Props {
ablaufdaten: Ablaufdatumeintrag[]; ablaufdaten: Ablaufdatumeintrag[];
} }
export function AblaufdatenTile({ ablaufdaten }: Props) { function AblaufdatenTileKomponente({ ablaufdaten }: Props) {
if (ablaufdaten.length === 0) { if (ablaufdaten.length === 0) {
return <p className="text-muted">Keine bald ablaufenden Chargen.</p>; return <p className="text-muted">Keine bald ablaufenden Chargen.</p>;
} }
@@ -26,3 +27,5 @@ export function AblaufdatenTile({ ablaufdaten }: Props) {
</ul> </ul>
); );
} }
export const AblaufdatenTile = memo(AblaufdatenTileKomponente);
@@ -1,3 +1,4 @@
import { memo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { HistorieEintrag } from "../../../api/types"; import type { HistorieEintrag } from "../../../api/types";
@@ -7,7 +8,7 @@ interface Props {
aktivitaet: HistorieEintrag[]; aktivitaet: HistorieEintrag[];
} }
export function AktivitaetTile({ aktivitaet }: Props) { function AktivitaetTileKomponente({ aktivitaet }: Props) {
if (aktivitaet.length === 0) { if (aktivitaet.length === 0) {
return <p className="text-muted">Noch keine Ereignisse.</p>; return <p className="text-muted">Noch keine Ereignisse.</p>;
} }
@@ -32,3 +33,5 @@ export function AktivitaetTile({ aktivitaet }: Props) {
</ul> </ul>
); );
} }
export const AktivitaetTile = memo(AktivitaetTileKomponente);
@@ -1,3 +1,4 @@
import { memo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Einsatzbereitschaft, Kennzahlen } from "../types"; import type { Einsatzbereitschaft, Kennzahlen } from "../types";
@@ -11,7 +12,7 @@ interface Props {
bereitschaft: Einsatzbereitschaft; bereitschaft: Einsatzbereitschaft;
} }
export function AufgabenTile({ function AufgabenTileKomponente({
kennzahlen, kennzahlen,
maengelNeuAnzahl, maengelNeuAnzahl,
maengelKritischAnzahl, maengelKritischAnzahl,
@@ -50,9 +51,11 @@ export function AufgabenTile({
return ( return (
<ul className="card-list"> <ul className="card-list">
{aufgaben.map((a, i) => ( {aufgaben.map((a) => (
<li key={i}>{a.ziel ? <Link to={a.ziel}>{a.text}</Link> : <span>{a.text}</span>}</li> <li key={a.text}>{a.ziel ? <Link to={a.ziel}>{a.text}</Link> : <span>{a.text}</span>}</li>
))} ))}
</ul> </ul>
); );
} }
export const AufgabenTile = memo(AufgabenTileKomponente);
@@ -1,4 +1,4 @@
import { useState } from "react"; import { memo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { GRUND_TEXT } from "../../../components/status/gruendeText"; import { GRUND_TEXT } from "../../../components/status/gruendeText";
@@ -18,7 +18,7 @@ interface Props {
bereitschaft: Einsatzbereitschaft; bereitschaft: Einsatzbereitschaft;
} }
export function EinsatzbereitschaftTile({ bereitschaft }: Props) { function EinsatzbereitschaftTileKomponente({ bereitschaft }: Props) {
const [detailsOffen, setDetailsOffen] = useState(false); const [detailsOffen, setDetailsOffen] = useState(false);
return ( return (
@@ -72,3 +72,5 @@ export function EinsatzbereitschaftTile({ bereitschaft }: Props) {
</> </>
); );
} }
export const EinsatzbereitschaftTile = memo(EinsatzbereitschaftTileKomponente);
@@ -1,10 +1,12 @@
import { memo } from "react";
import type { Kennzahlen } from "../types"; import type { Kennzahlen } from "../types";
interface Props { interface Props {
kennzahlen: Kennzahlen; kennzahlen: Kennzahlen;
} }
export function KennzahlenTile({ kennzahlen }: Props) { function KennzahlenTileKomponente({ kennzahlen }: Props) {
return ( return (
<div> <div>
<p className="text-4xl font-bold tabular-nums leading-none">{kennzahlen.anzahl_offener_fehlbestaende}</p> <p className="text-4xl font-bold tabular-nums leading-none">{kennzahlen.anzahl_offener_fehlbestaende}</p>
@@ -20,3 +22,5 @@ export function KennzahlenTile({ kennzahlen }: Props) {
</div> </div>
); );
} }
export const KennzahlenTile = memo(KennzahlenTileKomponente);
@@ -1,4 +1,4 @@
import { useState } from "react"; import { memo, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Mangel } from "../../../api/types"; import type { Mangel } from "../../../api/types";
@@ -9,9 +9,9 @@ interface Props {
maengelKritischAnzahl: number; maengelKritischAnzahl: number;
} }
export function MaengelTile({ maengel, maengelNeuAnzahl, maengelKritischAnzahl }: Props) { function MaengelTileKomponente({ maengel, maengelNeuAnzahl, maengelKritischAnzahl }: Props) {
const [maengelOffen, setMaengelOffen] = useState(false); const [maengelOffen, setMaengelOffen] = useState(false);
const maengelOffenListe = maengel.filter((m) => m.status !== "erledigt"); const maengelOffenListe = useMemo(() => maengel.filter((m) => m.status !== "erledigt"), [maengel]);
return ( return (
<> <>
@@ -53,3 +53,5 @@ export function MaengelTile({ maengel, maengelNeuAnzahl, maengelKritischAnzahl }
</> </>
); );
} }
export const MaengelTile = memo(MaengelTileKomponente);
@@ -1,4 +1,5 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { memo } from "react";
import { InspectionStatus } from "../../../components/status/InspectionStatus"; import { InspectionStatus } from "../../../components/status/InspectionStatus";
import type { Prueftermineintrag } from "../types"; import type { Prueftermineintrag } from "../types";
@@ -7,15 +8,15 @@ interface Props {
prueftermine: Prueftermineintrag[]; prueftermine: Prueftermineintrag[];
} }
export function PrueftermineTile({ prueftermine }: Props) { function PrueftermineTileKomponente({ prueftermine }: Props) {
if (prueftermine.length === 0) { if (prueftermine.length === 0) {
return <p className="text-muted">Keine anstehenden oder überfälligen Geräteprüfungen.</p>; return <p className="text-muted">Keine anstehenden oder überfälligen Geräteprüfungen.</p>;
} }
return ( return (
<ul className="card-list"> <ul className="card-list">
{prueftermine.map((p, i) => ( {prueftermine.map((p) => (
<li key={i} className="row-between" style={{ padding: "0.3rem 0" }}> <li key={`${p.typ}-${p.objekt_id}-${p.faelligkeitsdatum}`} className="row-between" style={{ padding: "0.3rem 0" }}>
<span> <span>
<Link to={`/akte/objekt/${p.objekt_id}`}>{p.bezeichnung}</Link>{" "} <Link to={`/akte/objekt/${p.objekt_id}`}>{p.bezeichnung}</Link>{" "}
<span className="text-muted">({p.faelligkeitsdatum})</span> <span className="text-muted">({p.faelligkeitsdatum})</span>
@@ -26,3 +27,5 @@ export function PrueftermineTile({ prueftermine }: Props) {
</ul> </ul>
); );
} }
export const PrueftermineTile = memo(PrueftermineTileKomponente);
@@ -1,10 +1,12 @@
import { memo } from "react";
import type { Qualifikationsablauf } from "../types"; import type { Qualifikationsablauf } from "../types";
interface Props { interface Props {
qualifikationsablaeufe: Qualifikationsablauf[]; qualifikationsablaeufe: Qualifikationsablauf[];
} }
export function QualifikationTile({ qualifikationsablaeufe }: Props) { function QualifikationTileKomponente({ qualifikationsablaeufe }: Props) {
if (qualifikationsablaeufe.length === 0) { if (qualifikationsablaeufe.length === 0) {
return <p className="text-muted">Keine bald ablaufenden Qualifikationen.</p>; return <p className="text-muted">Keine bald ablaufenden Qualifikationen.</p>;
} }
@@ -24,3 +26,5 @@ export function QualifikationTile({ qualifikationsablaeufe }: Props) {
</ul> </ul>
); );
} }
export const QualifikationTile = memo(QualifikationTileKomponente);