// backend/dashboard.go package main import ( "encoding/json" "net/http" "strconv" "strings" "time" ) type DashboardWidget struct { ID string `json:"id"` UserID string `json:"userId"` Type string `json:"type"` Title string `json:"title"` X int `json:"x"` Y int `json:"y"` W int `json:"w"` H int `json:"h"` Config json.RawMessage `json:"config"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type DashboardWidgetRequest struct { Type string `json:"type"` Title string `json:"title"` X int `json:"x"` Y int `json:"y"` W int `json:"w"` H int `json:"h"` Config json.RawMessage `json:"config"` } type DashboardOperationChange struct { ID string `json:"id"` OperationID string `json:"operationId"` OperationNumber string `json:"operationNumber"` OperationName string `json:"operationName"` UserDisplayName string `json:"userDisplayName"` Action string `json:"action"` Changes []OperationHistoryChange `json:"changes"` CreatedAt time.Time `json:"createdAt"` } type DashboardSettings struct { UserID string `json:"userId"` Settings json.RawMessage `json:"settings"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type DashboardSettingsRequest struct { Settings json.RawMessage `json:"settings"` } func scanDashboardSettings(scan func(dest ...any) error) (DashboardSettings, error) { var settings DashboardSettings var settingsRaw []byte err := scan( &settings.UserID, &settingsRaw, &settings.CreatedAt, &settings.UpdatedAt, ) if len(settingsRaw) == 0 { settingsRaw = []byte(`{}`) } settings.Settings = json.RawMessage(settingsRaw) return settings, err } func (s *Server) handleGetDashboardSettings(w http.ResponseWriter, r *http.Request) { user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } settings, err := scanDashboardSettings(func(dest ...any) error { return s.db.QueryRow( r.Context(), ` INSERT INTO dashboard_settings ( user_id, settings ) VALUES ( $1, '{}'::JSONB ) ON CONFLICT (user_id) DO UPDATE SET user_id = EXCLUDED.user_id RETURNING user_id::TEXT, settings, created_at, updated_at `, user.ID, ).Scan(dest...) }) if err != nil { writeError(w, http.StatusInternalServerError, "Dashboard-Einstellungen konnten nicht geladen werden") return } writeJSON(w, http.StatusOK, map[string]any{ "dashboardSettings": settings, }) } func (s *Server) handleUpdateDashboardSettings(w http.ResponseWriter, r *http.Request) { user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } var input DashboardSettingsRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } if len(input.Settings) == 0 || !json.Valid(input.Settings) { input.Settings = json.RawMessage(`{}`) } settings, err := scanDashboardSettings(func(dest ...any) error { return s.db.QueryRow( r.Context(), ` INSERT INTO dashboard_settings ( user_id, settings ) VALUES ( $1, $2::JSONB ) ON CONFLICT (user_id) DO UPDATE SET settings = EXCLUDED.settings, updated_at = now() RETURNING user_id::TEXT, settings, created_at, updated_at `, user.ID, string(input.Settings), ).Scan(dest...) }) if err != nil { writeError(w, http.StatusInternalServerError, "Dashboard-Einstellungen konnten nicht gespeichert werden") return } writeJSON(w, http.StatusOK, map[string]any{ "dashboardSettings": settings, }) } func normalizeDashboardWidgetInput(input *DashboardWidgetRequest) { input.Type = strings.TrimSpace(input.Type) input.Title = strings.TrimSpace(input.Title) if input.Type == "" { input.Type = "note" } if input.Title == "" { input.Title = "Widget" } if input.X < 0 { input.X = 0 } if input.Y < 0 { input.Y = 0 } if input.W < 2 { input.W = 2 } if input.W > 12 { input.W = 12 } if input.H < 1 { input.H = 1 } if input.H > 8 { input.H = 8 } if len(input.Config) == 0 || !json.Valid(input.Config) { input.Config = json.RawMessage(`{}`) } } func scanDashboardWidget(scan func(dest ...any) error) (DashboardWidget, error) { var widget DashboardWidget var configRaw []byte err := scan( &widget.ID, &widget.UserID, &widget.Type, &widget.Title, &widget.X, &widget.Y, &widget.W, &widget.H, &configRaw, &widget.CreatedAt, &widget.UpdatedAt, ) if len(configRaw) == 0 { configRaw = []byte(`{}`) } widget.Config = json.RawMessage(configRaw) return widget, err } func (s *Server) handleListDashboardWidgets(w http.ResponseWriter, r *http.Request) { user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } rows, err := s.db.Query( r.Context(), ` SELECT id::TEXT, user_id::TEXT, type, title, x, y, w, h, config, created_at, updated_at FROM dashboard_widgets WHERE user_id = $1 ORDER BY y ASC, x ASC, created_at ASC `, user.ID, ) if err != nil { writeError(w, http.StatusInternalServerError, "Dashboard konnte nicht geladen werden") return } defer rows.Close() widgets := []DashboardWidget{} for rows.Next() { widget, err := scanDashboardWidget(rows.Scan) if err != nil { writeError(w, http.StatusInternalServerError, "Dashboard konnte nicht gelesen werden") return } widgets = append(widgets, widget) } if err := rows.Err(); err != nil { writeError(w, http.StatusInternalServerError, "Dashboard konnte nicht gelesen werden") return } writeJSON(w, http.StatusOK, map[string]any{ "widgets": widgets, }) } func (s *Server) handleCreateDashboardWidget(w http.ResponseWriter, r *http.Request) { user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } var input DashboardWidgetRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } normalizeDashboardWidgetInput(&input) widget, err := scanDashboardWidget(func(dest ...any) error { return s.db.QueryRow( r.Context(), ` INSERT INTO dashboard_widgets ( user_id, type, title, x, y, w, h, config ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::JSONB) RETURNING id::TEXT, user_id::TEXT, type, title, x, y, w, h, config, created_at, updated_at `, user.ID, input.Type, input.Title, input.X, input.Y, input.W, input.H, string(input.Config), ).Scan(dest...) }) if err != nil { writeError(w, http.StatusInternalServerError, "Widget konnte nicht erstellt werden") return } writeJSON(w, http.StatusCreated, map[string]any{ "widget": widget, }) } func (s *Server) handleUpdateDashboardWidget(w http.ResponseWriter, r *http.Request) { user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } widgetID := strings.TrimSpace(r.PathValue("id")) if widgetID == "" { writeError(w, http.StatusBadRequest, "Widget-ID fehlt") return } var input DashboardWidgetRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } normalizeDashboardWidgetInput(&input) widget, err := scanDashboardWidget(func(dest ...any) error { return s.db.QueryRow( r.Context(), ` UPDATE dashboard_widgets SET type = $3, title = $4, x = $5, y = $6, w = $7, h = $8, config = $9::JSONB, updated_at = now() WHERE id = $1 AND user_id = $2 RETURNING id::TEXT, user_id::TEXT, type, title, x, y, w, h, config, created_at, updated_at `, widgetID, user.ID, input.Type, input.Title, input.X, input.Y, input.W, input.H, string(input.Config), ).Scan(dest...) }) if err != nil { writeError(w, http.StatusNotFound, "Widget wurde nicht gefunden") return } writeJSON(w, http.StatusOK, map[string]any{ "widget": widget, }) } func (s *Server) handleDeleteDashboardWidget(w http.ResponseWriter, r *http.Request) { user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } widgetID := strings.TrimSpace(r.PathValue("id")) if widgetID == "" { writeError(w, http.StatusBadRequest, "Widget-ID fehlt") return } commandTag, err := s.db.Exec( r.Context(), ` DELETE FROM dashboard_widgets WHERE id = $1 AND user_id = $2 `, widgetID, user.ID, ) if err != nil { writeError(w, http.StatusInternalServerError, "Widget konnte nicht gelöscht werden") return } if commandTag.RowsAffected() == 0 { writeError(w, http.StatusNotFound, "Widget wurde nicht gefunden") return } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, }) } func (s *Server) handleDashboardOperationChanges(w http.ResponseWriter, r *http.Request) { limit := 8 if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" { parsedLimit, err := strconv.Atoi(rawLimit) if err == nil { limit = parsedLimit } } if limit < 1 { limit = 1 } if limit > 50 { limit = 50 } rows, err := s.db.Query( r.Context(), ` SELECT h.id::TEXT, h.operation_id::TEXT, o.operation_number, o.operation_name, COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'), h.action, h.changes, h.created_at FROM operation_history h INNER JOIN operations o ON o.id = h.operation_id LEFT JOIN users u ON u.id = h.user_id WHERE h.action <> 'journal' ORDER BY h.created_at DESC LIMIT $1 `, limit, ) if err != nil { writeError(w, http.StatusInternalServerError, "Einsatzänderungen konnten nicht geladen werden") return } defer rows.Close() changes := []DashboardOperationChange{} for rows.Next() { var item DashboardOperationChange var changesRaw []byte if err := rows.Scan( &item.ID, &item.OperationID, &item.OperationNumber, &item.OperationName, &item.UserDisplayName, &item.Action, &changesRaw, &item.CreatedAt, ); err != nil { writeError(w, http.StatusInternalServerError, "Einsatzänderungen konnten nicht gelesen werden") return } item.Changes = []OperationHistoryChange{} if len(changesRaw) > 0 { if err := json.Unmarshal(changesRaw, &item.Changes); err != nil { writeError(w, http.StatusInternalServerError, "Einsatzänderungen konnten nicht gelesen werden") return } } changes = append(changes, item) } if err := rows.Err(); err != nil { writeError(w, http.StatusInternalServerError, "Einsatzänderungen konnten nicht gelesen werden") return } writeJSON(w, http.StatusOK, map[string]any{ "changes": changes, }) }