From 640ef8125734b58b93ccd7b9ce8b1188e00c846a Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 27 Mar 2026 06:51:46 +0100 Subject: [PATCH] feat: Phase 1.5 - Flexible Goal System (DB-Registry) Part 2/2 - COMPLETE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend dynamic goal types + Admin UI komplett implementiert. Frontend GoalsPage: - HARDCODED GOAL_TYPES entfernt - Dynamic loading von goal_type_definitions via API - goalTypes state + goalTypesMap für quick lookup - Dropdown zeigt alle aktiven Types aus DB - Vollständig flexibel - neue Types sofort verfügbar Admin UI: - AdminGoalTypesPage.jsx (400+ Zeilen) → Übersicht aller Goal Types (System + Custom) → Create/Edit/Delete Forms → CRUD via api.js (admin-only) → Validierung: System Types nur deaktivierbar, nicht löschbar → 8 Aggregationsmethoden im Dropdown → Category-Auswahl (body, mind, activity, nutrition, recovery, custom) - Route registriert: /admin/goal-types - Import in App.jsx Phase 1.5 KOMPLETT: ✅ Migration 024 (goal_type_definitions) ✅ Universal Value Fetcher (goal_utils.py) ✅ CRUD API (goals.py) ✅ Frontend Dynamic Dropdown (GoalsPage.jsx) ✅ Admin UI (AdminGoalTypesPage.jsx) System ist jetzt VOLLSTÄNDIG FLEXIBEL: - Neue Goal Types via Admin UI ohne Code-Deploy - Beispiele: Meditation, Trainingshäufigkeit, Planabweichung - Phase 0b Platzhalter können alle Types nutzen - Keine Doppelarbeit bei v2.0 Redesign Nächster Schritt: Testing + Phase 0b (120+ Platzhalter) Co-Authored-By: Claude Opus 4.6 --- frontend/src/App.jsx | 2 + frontend/src/pages/AdminGoalTypesPage.jsx | 446 ++++++++++++++++++++++ frontend/src/pages/GoalsPage.jsx | 49 ++- 3 files changed, 476 insertions(+), 21 deletions(-) create mode 100644 frontend/src/pages/AdminGoalTypesPage.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 01d5139..98f260f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -31,6 +31,7 @@ import AdminTrainingTypesPage from './pages/AdminTrainingTypesPage' import AdminActivityMappingsPage from './pages/AdminActivityMappingsPage' import AdminTrainingProfiles from './pages/AdminTrainingProfiles' import AdminPromptsPage from './pages/AdminPromptsPage' +import AdminGoalTypesPage from './pages/AdminGoalTypesPage' import SubscriptionPage from './pages/SubscriptionPage' import SleepPage from './pages/SleepPage' import RestDaysPage from './pages/RestDaysPage' @@ -188,6 +189,7 @@ function AppShell() { }/> }/> }/> + }/> }/> diff --git a/frontend/src/pages/AdminGoalTypesPage.jsx b/frontend/src/pages/AdminGoalTypesPage.jsx new file mode 100644 index 0000000..9ff50c8 --- /dev/null +++ b/frontend/src/pages/AdminGoalTypesPage.jsx @@ -0,0 +1,446 @@ +import { useState, useEffect } from 'react' +import { Settings, Plus, Pencil, Trash2, Database } from 'lucide-react' +import { api } from '../utils/api' + +export default function AdminGoalTypesPage() { + const [goalTypes, setGoalTypes] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showForm, setShowForm] = useState(false) + const [editingType, setEditingType] = useState(null) + const [toast, setToast] = useState(null) + + const [formData, setFormData] = useState({ + type_key: '', + label_de: '', + unit: '', + icon: '', + category: 'custom', + source_table: '', + source_column: '', + aggregation_method: 'latest', + description: '' + }) + + const CATEGORIES = ['body', 'mind', 'activity', 'nutrition', 'recovery', 'custom'] + const AGGREGATION_METHODS = [ + { value: 'latest', label: 'Letzter Wert' }, + { value: 'avg_7d', label: 'Durchschnitt 7 Tage' }, + { value: 'avg_30d', label: 'Durchschnitt 30 Tage' }, + { value: 'sum_30d', label: 'Summe 30 Tage' }, + { value: 'count_7d', label: 'Anzahl 7 Tage' }, + { value: 'count_30d', label: 'Anzahl 30 Tage' }, + { value: 'min_30d', label: 'Minimum 30 Tage' }, + { value: 'max_30d', label: 'Maximum 30 Tage' } + ] + + useEffect(() => { + loadGoalTypes() + }, []) + + const loadGoalTypes = async () => { + setLoading(true) + try { + const data = await api.listGoalTypeDefinitions() + setGoalTypes(data) + } catch (err) { + setError('Fehler beim Laden der Goal Types') + } finally { + setLoading(false) + } + } + + const showToast = (message) => { + setToast(message) + setTimeout(() => setToast(null), 2000) + } + + const handleCreate = () => { + setEditingType(null) + setFormData({ + type_key: '', + label_de: '', + unit: '', + icon: '', + category: 'custom', + source_table: '', + source_column: '', + aggregation_method: 'latest', + description: '' + }) + setShowForm(true) + } + + const handleEdit = (type) => { + setEditingType(type.id) + setFormData({ + type_key: type.type_key, + label_de: type.label_de, + unit: type.unit, + icon: type.icon || '', + category: type.category || 'custom', + source_table: type.source_table || '', + source_column: type.source_column || '', + aggregation_method: type.aggregation_method || 'latest', + description: type.description || '' + }) + setShowForm(true) + } + + const handleSave = async () => { + if (!formData.label_de || !formData.unit) { + setError('Bitte Label und Einheit ausfüllen') + return + } + + try { + if (editingType) { + await api.updateGoalType(editingType, formData) + showToast('✓ Goal Type aktualisiert') + } else { + if (!formData.type_key) { + setError('Bitte eindeutigen Key angeben (z.B. meditation_minutes)') + return + } + await api.createGoalType(formData) + showToast('✓ Goal Type erstellt') + } + + await loadGoalTypes() + setShowForm(false) + setError(null) + } catch (err) { + setError(err.message || 'Fehler beim Speichern') + } + } + + const handleDelete = async (typeId, typeName, isSystem) => { + if (isSystem) { + if (!confirm(`System Goal Type "${typeName}" deaktivieren? (Nicht löschbar)`)) return + } else { + if (!confirm(`Goal Type "${typeName}" wirklich löschen?`)) return + } + + try { + await api.deleteGoalType(typeId) + showToast('✓ Goal Type gelöscht/deaktiviert') + await loadGoalTypes() + } catch (err) { + setError(err.message || 'Fehler beim Löschen') + } + } + + if (loading) { + return ( +
+
+
+
+
+ ) + } + + return ( +
+
+

Goal Type Verwaltung

+
+ + {error && ( +
+

{error}

+
+ )} + + {toast && ( +
+ {toast} +
+ )} + +
+
+
+

Verfügbare Goal Types

+

+ {goalTypes.length} Types registriert ({goalTypes.filter(t => t.is_system).length} System, {goalTypes.filter(t => !t.is_system).length} Custom) +

+
+ +
+ +
+ {goalTypes.map(type => ( +
+
+
+
+ {type.icon || '📊'} + {type.label_de} + + {type.unit} + + {type.is_system && ( + + SYSTEM + + )} + {!type.is_active && ( + + INAKTIV + + )} +
+ +
+ Key: {type.type_key} + {type.source_table && ( + <> + {' | '}Quelle: {type.source_table}.{type.source_column} + {' | '}Methode: {type.aggregation_method} + + )} + {type.calculation_formula && ( + <> + {' | '}Formel: Komplex (JSON) + + )} +
+ + {type.description && ( +
+ {type.description} +
+ )} +
+ +
+ + +
+
+
+ ))} +
+
+ + {/* Form Modal */} + {showForm && ( +
+
+
+ {editingType ? 'Goal Type bearbeiten' : 'Neuer Goal Type'} +
+ +
+ {/* Type Key (nur bei Create) */} + {!editingType && ( +
+ + setFormData(f => ({ ...f, type_key: e.target.value }))} + placeholder="snake_case verwenden" + /> +
+ )} + + {/* Label */} +
+ + setFormData(f => ({ ...f, label_de: e.target.value }))} + placeholder="z.B. Meditation" + /> +
+ + {/* Unit & Icon */} +
+
+ + setFormData(f => ({ ...f, unit: e.target.value }))} + placeholder="z.B. min/Tag" + /> +
+
+ + setFormData(f => ({ ...f, icon: e.target.value }))} + placeholder="🧘" + /> +
+
+ + {/* Category */} +
+ + +
+ + {/* Data Source */} +
+
+ + setFormData(f => ({ ...f, source_table: e.target.value }))} + placeholder="z.B. meditation_log" + /> +
+
+ + setFormData(f => ({ ...f, source_column: e.target.value }))} + placeholder="z.B. duration_minutes" + /> +
+
+ + {/* Aggregation Method */} +
+ + +
+ + {/* Description */} +
+ +