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; } interface DashboardState { dashboards: DashboardTab[]; activeDashboardId: string | null; isSwitchingDashboard: boolean; layouts: Record>; widgets: WidgetInstance[]; isEditMode: boolean; isDirty: boolean; isLoading: boolean; error: string | null; setEditMode: (mode: boolean) => void; updateLayouts: (layouts: Record) => void; addWidget: (widgetType: WidgetType) => Promise; removeWidget: (id: string) => Promise; /** Teil-Konfiguration eines Widgets setzen — sofort sichtbar, bei Fehler zurueck. */ setWidgetConfig: (id: string, partial: Record) => Promise; loadDashboard: () => Promise; saveLayout: () => Promise; selectDashboard: (id: string) => Promise; createDashboard: () => Promise; renameDashboard: (id: string, name: string) => Promise; deleteDashboard: (id: string) => Promise; reorderDashboards: (ids: string[]) => Promise; } /** * 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 | null = null; function loadDashboardsOnce(): Promise { 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()((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) => { const before = get().widgets.find((w) => w.id === id)?.config; if (!before) return; const patch = (config: Record) => 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 }); } }, }));