12eea333ba
Neuer Schalter im Bearbeitungsmodus (neben dem Griff) setzt hideTitle in der Widget-Konfiguration (setWidgetConfig im Dashboard-Store, optimistisch mit Ruecksetzen); in der Ansicht entfaellt die Titelzeile per data-hide-title, Aktionen der Titelzeile bleiben oben rechts. Kategorien der Seitenleiste 14 px, Moduleintraege 13 px / 32 px hoch. Version 1.5.2. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
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';
|
|
|
|
export interface WidgetInstance {
|
|
id: string;
|
|
widgetType: string;
|
|
config: Record<string, unknown>;
|
|
}
|
|
|
|
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;
|
|
isDirty: boolean;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
|
|
setEditMode: (mode: boolean) => void;
|
|
updateLayouts: (layouts: Record<string, unknown>) => void;
|
|
addWidget: (widgetType: WidgetType) => Promise<void>;
|
|
removeWidget: (id: string) => Promise<void>;
|
|
/** Teil-Konfiguration eines Widgets setzen — sofort sichtbar, bei Fehler zurueck. */
|
|
setWidgetConfig: (id: string, partial: Record<string, unknown>) => 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>;
|
|
reorderDashboards: (ids: 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;
|
|
}
|
|
|
|
/**
|
|
* Dashboard Zustand store. NO persist middleware — layout comes from DB (D-05).
|
|
* Save only on exiting edit mode (D-01), not on every drag.
|
|
*
|
|
* quick-260916-bwo: Gespeicherte Anordnungen in alten Raster-Einheiten werden
|
|
* beim Laden einmalig umgerechnet (`migrateGridLayouts`) und sofort mit dem
|
|
* Marker `__gridVersion` zurueckgeschrieben. Der Marker lebt NUR im
|
|
* gespeicherten JSON, nie im Zustand (dieser Store iteriert mit Object.keys
|
|
* 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,
|
|
isDirty: false,
|
|
isLoading: false,
|
|
error: null,
|
|
|
|
setEditMode: (mode: boolean) => {
|
|
const prev = get().isEditMode;
|
|
set({ isEditMode: mode });
|
|
// On exit edit mode, auto-save if dirty (D-01)
|
|
if (prev && !mode && get().isDirty) {
|
|
get().saveLayout();
|
|
}
|
|
},
|
|
|
|
updateLayouts: (layouts) => {
|
|
set({
|
|
layouts: layouts as DashboardState['layouts'],
|
|
isDirty: true,
|
|
});
|
|
},
|
|
|
|
addWidget: async (widgetType: WidgetType) => {
|
|
const dashboardId = get().activeDashboardId;
|
|
if (!dashboardId) return;
|
|
try {
|
|
const newWidget = await api.addWidget(dashboardId, widgetType);
|
|
const constraints = WIDGET_CONSTRAINTS[widgetType];
|
|
|
|
set((state) => {
|
|
// Add default layout entry for all breakpoints
|
|
const newLayouts = { ...state.layouts };
|
|
for (const bp of Object.keys(newLayouts)) {
|
|
const arr = [...(newLayouts[bp] || [])];
|
|
// Place at next available y position
|
|
const maxY = arr.reduce((max, item) => Math.max(max, item.y + item.h), 0);
|
|
arr.push({
|
|
i: newWidget.id,
|
|
x: 0,
|
|
y: maxY,
|
|
w: constraints.defaultW,
|
|
h: constraints.defaultH,
|
|
});
|
|
newLayouts[bp] = arr;
|
|
}
|
|
|
|
return {
|
|
widgets: [...state.widgets, newWidget],
|
|
layouts: newLayouts,
|
|
isDirty: true,
|
|
};
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to add widget:', err);
|
|
}
|
|
},
|
|
|
|
removeWidget: async (id: string) => {
|
|
try {
|
|
await api.removeWidget(id);
|
|
set((state) => {
|
|
const newLayouts = { ...state.layouts };
|
|
for (const bp of Object.keys(newLayouts)) {
|
|
newLayouts[bp] = newLayouts[bp].filter((item) => item.i !== id);
|
|
}
|
|
return {
|
|
widgets: state.widgets.filter((w) => w.id !== id),
|
|
layouts: newLayouts,
|
|
isDirty: true,
|
|
};
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to remove widget:', err);
|
|
}
|
|
},
|
|
|
|
setWidgetConfig: async (id: string, partial: Record<string, unknown>) => {
|
|
const before = get().widgets.find((w) => w.id === id)?.config;
|
|
if (!before) return;
|
|
const patch = (config: Record<string, unknown>) =>
|
|
set((state) => ({
|
|
widgets: state.widgets.map((w) => (w.id === id ? { ...w, config } : w)),
|
|
}));
|
|
patch({ ...before, ...partial });
|
|
try {
|
|
await api.updateWidgetConfig(id, partial);
|
|
} catch (err) {
|
|
console.error('Failed to update widget config:', err);
|
|
patch(before);
|
|
}
|
|
},
|
|
|
|
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(first.id),
|
|
api.fetchWidgets(first.id),
|
|
]);
|
|
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
|
|
set({
|
|
dashboards,
|
|
activeDashboardId: first.id,
|
|
layouts: migratedLayouts,
|
|
widgets,
|
|
isLoading: false,
|
|
});
|
|
// Umgerechnete Anordnung SOFORT mit Marker persistieren — nach dem set,
|
|
// damit die Oberflaeche unabhaengig vom Speichern rendert; eigener
|
|
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
|
|
if (migrated) {
|
|
try {
|
|
await api.saveLayout(first.id, withGridVersion(migratedLayouts));
|
|
} catch (err) {
|
|
console.error('Failed to persist migrated layout:', err);
|
|
}
|
|
}
|
|
} catch {
|
|
set({
|
|
error: 'Failed to load dashboard',
|
|
isLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
saveLayout: async () => {
|
|
const dashboardId = get().activeDashboardId;
|
|
if (!dashboardId) return;
|
|
try {
|
|
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);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Persistiert eine per Ziehen bestimmte Reihenfolge (quick-260923-ad9,
|
|
* Task 4). Setzt die neue Reihenfolge SOFORT im Zustand (optimistisch —
|
|
* die Leiste in `dashboard-tabs.tsx` zeigt schon ihre eigene Vorschau
|
|
* waehrend des Ziehens, dieses `set` uebernimmt sie beim Loslassen als
|
|
* Tatsache) und stellt bei einem fehlgeschlagenen Speichern die VORHERIGE
|
|
* Reihenfolge wieder her. Der aktive Reiter bleibt aktiv, auch wenn er
|
|
* seine Position wechselt — `activeDashboardId` bleibt unberuehrt.
|
|
*/
|
|
reorderDashboards: async (ids: string[]) => {
|
|
const previous = get().dashboards;
|
|
const byId = new Map(previous.map((d) => [d.id, d]));
|
|
const optimistic = ids.map((id) => byId.get(id)).filter((d): d is DashboardTab => d !== undefined);
|
|
set({ dashboards: optimistic });
|
|
|
|
try {
|
|
const updated = await api.reorderDashboardTabs(ids);
|
|
set({ dashboards: updated });
|
|
} catch (err) {
|
|
console.error('Failed to reorder dashboards:', err);
|
|
set({ dashboards: previous });
|
|
}
|
|
},
|
|
}));
|