feat(quick-260923-ad9): Reiterleiste in der Oberflaeche - Task 3

dashboard-api.ts: fuenf neue Abrufe fuer die Reiter-Wege, die vier
bestehenden Aufrufe (Layout/Widgets lesen/schreiben) tragen jetzt die
Reiter-Kennung (Abfrageparameter bzw. Rumpf).

dashboard-store.ts: Zustand um dashboards/activeDashboardId/
isSwitchingDashboard erweitert. loadDashboard() holt zuerst die Reiter,
macht den ersten aktiv, laedt erst danach dessen Inhalt; ein modul-globales
Versprechen schuetzt gegen doppeltes Laden der Reiterliste bei doppeltem
Einhaengen. selectDashboard() schreibt eine ungespeicherte Anordnung ZUERST
fuer den alten Reiter (Kennung vor dem Wechsel gelesen) und ersetzt danach
Kacheln/Anordnung vollstaendig. createDashboard/renameDashboard/
deleteDashboard pflegen Reiterliste und aktiven Reiter; Loeschen des
aktiven Reiters macht den dann ersten Reiter aktiv. Die Marker-Umrechnung
(quick-260916-bwo) laeuft unveraendert je Reiter mit, auch beim Wechsel.

dashboard-tabs.tsx (neu): Reiterleiste, Klick wechselt immer; im
Bearbeitungsmodus zusaetzlich Anlegen, Umbenennen (an Ort und Stelle,
Enter/Escape) und Loeschen (mit Rueckfrage) - Loeschen-Knopf fehlt beim
letzten verbleibenden Reiter (D-10). Fokus beim Umbenennen ueber einen Ref
statt autoFocus (lint/a11y/noAutofocus).

(portal)/page.tsx: Leiste ueber dem Raster, kurze Ladezeile waehrend eines
Reiterwechsels statt des Rasters - die Leiste bleibt stehen. DashboardGrid
selbst unveraendert (D-06).

Uebersetzungen: neue Schluessel unter widgets.tabs.* in de.json/en.json,
Dialog-Knoepfe nutzen die vorhandenen common.cancel/common.delete.

Deviations (Rule 3 - blockierende Nachwirkung dieses Tasks, ausserhalb der
files_modified-Liste, aber direkt durch die dashboardId-Pflicht verursacht):
- settings/dashboard/page.tsx: fetchWidgets() verlangt jetzt eine
  Reiter-Kennung; die Seite ist nicht reiterbewusst (ausserhalb des
  Umfangs) und zeigt jetzt die Kacheln des ERSTEN Reiters - deckungsgleich
  mit dem bisherigen Verhalten fuer den haeufigen Fall genau eines Reiters.
- (portal)/page.test.tsx: mockStore brauchte die neuen Reiter-Felder/
  -Methoden, sonst waere DashboardTabs auf `dashboards.map` von undefined
  gescheitert.

Tests: dashboard-store.test.ts 15 (6 alte angepasste Signaturen + 9 neue),
dashboard-tabs.test.tsx 12 (neu). web gesamt 682 Tests in 82 Dateien,
dashboard-grid.test.tsx unveraendert bei 12. type-check 4/4, lint 5/5 mit
weiterhin genau 53 Warnungen in web.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 08:10:45 +02:00
parent df7a5e7e8e
commit d34f682c28
10 changed files with 999 additions and 40 deletions
+152 -5
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import * as api from '@/lib/dashboard-api';
import type { DashboardTab } from '@/lib/dashboard-api';
import type { WidgetType } from '@/components/dashboard/widget-registry';
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
@@ -11,6 +12,9 @@ export interface WidgetInstance {
}
interface DashboardState {
dashboards: DashboardTab[];
activeDashboardId: string | null;
isSwitchingDashboard: boolean;
layouts: Record<string, Array<{ i: string; x: number; y: number; w: number; h: number }>>;
widgets: WidgetInstance[];
isEditMode: boolean;
@@ -24,6 +28,33 @@ interface DashboardState {
removeWidget: (id: string) => Promise<void>;
loadDashboard: () => Promise<void>;
saveLayout: () => Promise<void>;
selectDashboard: (id: string) => Promise<void>;
createDashboard: () => Promise<void>;
renameDashboard: (id: string, name: string) => Promise<void>;
deleteDashboard: (id: string) => Promise<void>;
}
/**
* Schutz gegen doppeltes Laden der Reiterliste (quick-260923-ad9, Task 3,
* T-AD9-07 — Gegenstück zur Transaktionssperre in `listDashboards` auf der
* API): ein modul-globales Versprechen statt eines Zustandsfeldes, damit
* ZWEI synchron hintereinander ausgeloeste `loadDashboard()`-Aufrufe (z. B.
* React StrictMode, doppeltes Einhaengen) sich dasselbe, noch nicht
* aufgeloeste Versprechen teilen — `api.fetchDashboards()` laeuft dann nur
* EINMAL. Nach Abschluss (Erfolg ODER Fehler) wird die Sperre zurueckgesetzt,
* damit ein spaeteres, echtes Neuladen (Seite erneut besucht, langer
* Zeitabstand) wieder ein frisches Netzwerkergebnis holt statt fuer immer
* auf dem ersten Ergebnis zu bleiben.
*/
let dashboardsLoadPromise: Promise<DashboardTab[]> | null = null;
function loadDashboardsOnce(): Promise<DashboardTab[]> {
if (!dashboardsLoadPromise) {
dashboardsLoadPromise = api.fetchDashboards().finally(() => {
dashboardsLoadPromise = null;
});
}
return dashboardsLoadPromise;
}
/**
@@ -37,8 +68,19 @@ interface DashboardState {
* ueber die Breakpoints). Fehlt der Marker beim Speichern, wird beim naechsten
* Laden erneut verdoppelt — deshalb `withGridVersion` an BEIDEN Speicherstellen
* (Sofort-Speichern nach der Umrechnung und `saveLayout`), T-BWO-02.
*
* quick-260923-ad9 (Task 3): Reiter — mehrere Dashboards je Benutzer. Der
* erste (`position` aufsteigend) ist der Standard und wird beim Laden aktiv.
* `layouts`/`widgets` gehoeren IMMER zum `activeDashboardId` — ein
* Reiterwechsel ersetzt beide vollstaendig, nie ein Zusammenfuehren. Die
* Marker-Umrechnung oben gilt weiterhin je Reiter: `selectDashboard`
* durchlaeuft dieselbe Umrechnung-plus-Sofort-Speichern-Logik wie
* `loadDashboard`.
*/
export const useDashboardStore = create<DashboardState>()((set, get) => ({
dashboards: [],
activeDashboardId: null,
isSwitchingDashboard: false,
layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] },
widgets: [],
isEditMode: false,
@@ -63,8 +105,10 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
},
addWidget: async (widgetType: WidgetType) => {
const dashboardId = get().activeDashboardId;
if (!dashboardId) return;
try {
const newWidget = await api.addWidget(widgetType);
const newWidget = await api.addWidget(dashboardId, widgetType);
const constraints = WIDGET_CONSTRAINTS[widgetType];
set((state) => {
@@ -117,12 +161,21 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
loadDashboard: async () => {
set({ isLoading: true, error: null });
try {
const dashboards = await loadDashboardsOnce();
const first = dashboards[0];
if (!first) {
set({ error: 'Failed to load dashboard', isLoading: false });
return;
}
const [rawLayouts, widgets] = await Promise.all([
api.fetchLayout(),
api.fetchWidgets(),
api.fetchLayout(first.id),
api.fetchWidgets(first.id),
]);
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
set({
dashboards,
activeDashboardId: first.id,
layouts: migratedLayouts,
widgets,
isLoading: false,
@@ -132,7 +185,7 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
if (migrated) {
try {
await api.saveLayout(withGridVersion(migratedLayouts));
await api.saveLayout(first.id, withGridVersion(migratedLayouts));
} catch (err) {
console.error('Failed to persist migrated layout:', err);
}
@@ -146,11 +199,105 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
},
saveLayout: async () => {
const dashboardId = get().activeDashboardId;
if (!dashboardId) return;
try {
await api.saveLayout(withGridVersion(get().layouts));
await api.saveLayout(dashboardId, withGridVersion(get().layouts));
set({ isDirty: false });
} catch (err) {
console.error('Failed to save layout:', err);
}
},
/**
* Wechselt den aktiven Reiter (quick-260923-ad9, Task 3). Eine
* ungespeicherte Anordnung wird ZUERST fuer den ALTEN Reiter geschrieben
* (`get().saveLayout()` liest `activeDashboardId` VOR dem `set` unten,
* schreibt also unter der Kennung des alten Reiters) — erst danach werden
* Kacheln und Anordnung des neuen Reiters geladen und ersetzen den
* Zustand VOLLSTAENDIG (kein Zusammenfuehren).
*/
selectDashboard: async (id: string) => {
const state = get();
if (id === state.activeDashboardId) return;
if (state.isDirty) {
await get().saveLayout();
}
set({ isSwitchingDashboard: true, error: null });
try {
const [rawLayouts, widgets] = await Promise.all([
api.fetchLayout(id),
api.fetchWidgets(id),
]);
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
set({
activeDashboardId: id,
layouts: migratedLayouts,
widgets,
isDirty: false,
isSwitchingDashboard: false,
});
if (migrated) {
try {
await api.saveLayout(id, withGridVersion(migratedLayouts));
} catch (err) {
console.error('Failed to persist migrated layout:', err);
}
}
} catch (err) {
console.error('Failed to switch dashboard:', err);
set({ isSwitchingDashboard: false, error: 'Failed to switch dashboard' });
}
},
/** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */
createDashboard: async () => {
try {
const created = await api.createDashboardTab();
set((state) => ({ dashboards: [...state.dashboards, created] }));
await get().selectDashboard(created.id);
} catch (err) {
console.error('Failed to create dashboard:', err);
}
},
renameDashboard: async (id: string, name: string) => {
try {
const updated = await api.renameDashboardTab(id, name);
set((state) => ({
dashboards: state.dashboards.map((d) => (d.id === id ? updated : d)),
}));
} catch (err) {
console.error('Failed to rename dashboard:', err);
}
},
/**
* Loescht einen Reiter. Beim aktiven Reiter wird danach der dann erste
* verbleibende Reiter aktiv und sein Inhalt geladen (`selectDashboard`).
* Die verbleibende Liste wird lokal gefiltert statt neu geladen — die vom
* Server neu vergebenen `position`-Werte werden dabei nicht nachgezogen;
* das ist fuer die Reihenfolge in der Leiste unschaedlich (sie filtert nur,
* sortiert nicht neu), ein spaeteres Umsortieren sendet ohnehin die
* VOLLSTAENDIGE Kennungsliste, nicht die Positionszahlen.
*/
deleteDashboard: async (id: string) => {
try {
await api.deleteDashboardTab(id);
const remaining = get().dashboards.filter((d) => d.id !== id);
set({ dashboards: remaining });
if (get().activeDashboardId === id) {
const next = remaining[0];
if (next) {
set({ activeDashboardId: null });
await get().selectDashboard(next.id);
}
}
} catch (err) {
console.error('Failed to delete dashboard:', err);
}
},
}));