3f5afb0f54
- dashboard-grid.tsx: COLS 24/20/12/8/2, rowHeight 20, margin 8 (containerPadding folgt), Rueckfallwerte 4 - widget-registry.tsx: alle 32 Werte in WIDGET_CONSTRAINTS verdoppelt - grid-layout-migration.ts (neu): migrateGridLayouts/withGridVersion, Marker nur im JSON, Idempotenz (T-BWO-02) - dashboard-store.ts: Umrechnung beim Laden, Sofort-Speichern mit Marker, withGridVersion bei jedem saveLayout - Tests: Migration 7 (neu), Store 6 (neu), Registry +1 (Tabelle), Grid +2 (Props ueber Mock), API-Spec +2 (Durchreichung __gridVersion, timeFontSizePt) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
157 lines
4.8 KiB
TypeScript
157 lines
4.8 KiB
TypeScript
import { create } from 'zustand';
|
|
import * as api 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 {
|
|
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>;
|
|
loadDashboard: () => Promise<void>;
|
|
saveLayout: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
|
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) => {
|
|
try {
|
|
const newWidget = await api.addWidget(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);
|
|
}
|
|
},
|
|
|
|
loadDashboard: async () => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const [rawLayouts, widgets] = await Promise.all([
|
|
api.fetchLayout(),
|
|
api.fetchWidgets(),
|
|
]);
|
|
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
|
|
set({
|
|
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(withGridVersion(migratedLayouts));
|
|
} catch (err) {
|
|
console.error('Failed to persist migrated layout:', err);
|
|
}
|
|
}
|
|
} catch {
|
|
set({
|
|
error: 'Failed to load dashboard',
|
|
isLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
saveLayout: async () => {
|
|
try {
|
|
await api.saveLayout(withGridVersion(get().layouts));
|
|
set({ isDirty: false });
|
|
} catch (err) {
|
|
console.error('Failed to save layout:', err);
|
|
}
|
|
},
|
|
}));
|