feat: dynamic schema dropdowns for goal type admin UI
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s

Admin can now easily create custom goal types:
- New endpoint /api/goals/schema-info with table/column metadata
- 9 tables documented (weight, caliper, activity, nutrition, sleep, vitals, BP, rest_days, circumference)
- Table dropdown with descriptions (e.g., 'activity_log - Trainingseinheiten')
- Column dropdown dependent on selected table
- All columns documented in German with data types
- Fields optional (for complex calculation formulas)

UX improvements:
- No need to guess table/column names
- Clear descriptions for each field
- Type-safe selection (no typos)
- Cascading dropdowns (column depends on table)

Closes user feedback: 'Admin weiß nicht welche Tabellen/Spalten verfügbar sind'
This commit is contained in:
Lars 2026-03-27 08:05:45 +01:00
parent 210671059a
commit 2c978bf948
3 changed files with 162 additions and 19 deletions

View File

@ -508,6 +508,108 @@ def _calculate_norm_category(test_type: str, value: float, unit: str) -> Optiona
# Goal Type Definitions (Phase 1.5 - Flexible Goal System) # Goal Type Definitions (Phase 1.5 - Flexible Goal System)
# ============================================================================ # ============================================================================
@router.get("/schema-info")
def get_schema_info(session: dict = Depends(require_auth)):
"""
Get available tables and columns for goal type creation.
Admin-only endpoint for building custom goal types.
Returns structure with descriptions for UX guidance.
"""
pid = session['profile_id']
# Check admin role
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT role FROM profiles WHERE id = %s", (pid,))
profile = cur.fetchone()
if not profile or profile['role'] != 'admin':
raise HTTPException(status_code=403, detail="Admin-Zugriff erforderlich")
# Define relevant tables with descriptions
# Only include tables that make sense for goal tracking
schema = {
"weight_log": {
"description": "Gewichtsverlauf",
"columns": {
"weight": {"type": "DECIMAL", "description": "Körpergewicht in kg"}
}
},
"caliper_log": {
"description": "Caliper-Messungen (Hautfalten)",
"columns": {
"body_fat_pct": {"type": "DECIMAL", "description": "Körperfettanteil in %"},
"sum_mm": {"type": "DECIMAL", "description": "Summe Hautfalten in mm"}
}
},
"circumference_log": {
"description": "Umfangsmessungen",
"columns": {
"c_neck": {"type": "DECIMAL", "description": "Nackenumfang in cm"},
"c_chest": {"type": "DECIMAL", "description": "Brustumfang in cm"},
"c_waist": {"type": "DECIMAL", "description": "Taillenumfang in cm"},
"c_hips": {"type": "DECIMAL", "description": "Hüftumfang in cm"},
"c_thigh_l": {"type": "DECIMAL", "description": "Oberschenkel links in cm"},
"c_thigh_r": {"type": "DECIMAL", "description": "Oberschenkel rechts in cm"},
"c_calf_l": {"type": "DECIMAL", "description": "Wade links in cm"},
"c_calf_r": {"type": "DECIMAL", "description": "Wade rechts in cm"},
"c_bicep_l": {"type": "DECIMAL", "description": "Bizeps links in cm"},
"c_bicep_r": {"type": "DECIMAL", "description": "Bizeps rechts in cm"}
}
},
"activity_log": {
"description": "Trainingseinheiten",
"columns": {
"id": {"type": "UUID", "description": "ID (für Zählung von Einheiten)"},
"duration_minutes": {"type": "INTEGER", "description": "Trainingsdauer in Minuten"},
"perceived_exertion": {"type": "INTEGER", "description": "Belastungsempfinden (1-10)"},
"quality_rating": {"type": "INTEGER", "description": "Qualitätsbewertung (1-10)"}
}
},
"nutrition_log": {
"description": "Ernährungstagebuch",
"columns": {
"calories": {"type": "INTEGER", "description": "Kalorien in kcal"},
"protein_g": {"type": "DECIMAL", "description": "Protein in g"},
"carbs_g": {"type": "DECIMAL", "description": "Kohlenhydrate in g"},
"fat_g": {"type": "DECIMAL", "description": "Fett in g"}
}
},
"sleep_log": {
"description": "Schlafprotokoll",
"columns": {
"total_minutes": {"type": "INTEGER", "description": "Gesamtschlafdauer in Minuten"}
}
},
"vitals_baseline": {
"description": "Vitalwerte (morgens)",
"columns": {
"resting_hr": {"type": "INTEGER", "description": "Ruhepuls in bpm"},
"hrv_rmssd": {"type": "INTEGER", "description": "Herzratenvariabilität (RMSSD) in ms"},
"vo2_max": {"type": "DECIMAL", "description": "VO2 Max in ml/kg/min"},
"spo2": {"type": "INTEGER", "description": "Sauerstoffsättigung in %"},
"respiratory_rate": {"type": "INTEGER", "description": "Atemfrequenz pro Minute"}
}
},
"blood_pressure_log": {
"description": "Blutdruckmessungen",
"columns": {
"systolic": {"type": "INTEGER", "description": "Systolischer Blutdruck in mmHg"},
"diastolic": {"type": "INTEGER", "description": "Diastolischer Blutdruck in mmHg"},
"pulse": {"type": "INTEGER", "description": "Puls in bpm"}
}
},
"rest_days": {
"description": "Ruhetage",
"columns": {
"id": {"type": "UUID", "description": "ID (für Zählung von Ruhetagen)"}
}
}
}
return schema
@router.get("/goal-types") @router.get("/goal-types")
def list_goal_type_definitions(session: dict = Depends(require_auth)): def list_goal_type_definitions(session: dict = Depends(require_auth)):
""" """

View File

@ -4,6 +4,7 @@ import { api } from '../utils/api'
export default function AdminGoalTypesPage() { export default function AdminGoalTypesPage() {
const [goalTypes, setGoalTypes] = useState([]) const [goalTypes, setGoalTypes] = useState([])
const [schemaInfo, setSchemaInfo] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
@ -42,9 +43,14 @@ export default function AdminGoalTypesPage() {
setLoading(true) setLoading(true)
setError(null) setError(null)
try { try {
const data = await api.listGoalTypeDefinitions() const [typesData, schema] = await Promise.all([
console.log('[DEBUG] Loaded goal types:', data) api.listGoalTypeDefinitions(),
setGoalTypes(data || []) api.getSchemaInfo()
])
console.log('[DEBUG] Loaded goal types:', typesData)
console.log('[DEBUG] Loaded schema info:', schema)
setGoalTypes(typesData || [])
setSchemaInfo(schema || {})
} catch (err) { } catch (err) {
console.error('[ERROR] Failed to load goal types:', err) console.error('[ERROR] Failed to load goal types:', err)
setError(`Fehler beim Laden der Goal Types: ${err.message || err.toString()}`) setError(`Fehler beim Laden der Goal Types: ${err.message || err.toString()}`)
@ -375,25 +381,59 @@ export default function AdminGoalTypesPage() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div> <div>
<label className="form-label">Tabelle</label> <label className="form-label">Tabelle</label>
<input {schemaInfo ? (
type="text" <select
className="form-input" className="form-input"
style={{ width: '100%' }} style={{ width: '100%' }}
value={formData.source_table} value={formData.source_table}
onChange={e => setFormData(f => ({ ...f, source_table: e.target.value }))} onChange={e => setFormData(f => ({ ...f, source_table: e.target.value, source_column: '' }))}
placeholder="z.B. meditation_log" >
/> <option value="">-- Optional --</option>
{Object.entries(schemaInfo).map(([table, info]) => (
<option key={table} value={table} title={info.description}>
{table} - {info.description}
</option>
))}
</select>
) : (
<input
type="text"
className="form-input"
style={{ width: '100%' }}
value={formData.source_table}
onChange={e => setFormData(f => ({ ...f, source_table: e.target.value }))}
placeholder="Lade Schema..."
disabled
/>
)}
</div> </div>
<div> <div>
<label className="form-label">Spalte</label> <label className="form-label">Spalte</label>
<input {schemaInfo && formData.source_table && schemaInfo[formData.source_table] ? (
type="text" <select
className="form-input" className="form-input"
style={{ width: '100%' }} style={{ width: '100%' }}
value={formData.source_column} value={formData.source_column}
onChange={e => setFormData(f => ({ ...f, source_column: e.target.value }))} onChange={e => setFormData(f => ({ ...f, source_column: e.target.value }))}
placeholder="z.B. duration_minutes" >
/> <option value="">-- Wählen --</option>
{Object.entries(schemaInfo[formData.source_table].columns).map(([col, info]) => (
<option key={col} value={col} title={info.description}>
{col} - {info.description}
</option>
))}
</select>
) : (
<input
type="text"
className="form-input"
style={{ width: '100%' }}
value={formData.source_column}
onChange={e => setFormData(f => ({ ...f, source_column: e.target.value }))}
placeholder={formData.source_table ? "Spalte wählen..." : "Erst Tabelle wählen"}
disabled={!formData.source_table}
/>
)}
</div> </div>
</div> </div>

View File

@ -344,6 +344,7 @@ export const api = {
createGoalType: (d) => req('/goals/goal-types', json(d)), createGoalType: (d) => req('/goals/goal-types', json(d)),
updateGoalType: (id,d) => req(`/goals/goal-types/${id}`, jput(d)), updateGoalType: (id,d) => req(`/goals/goal-types/${id}`, jput(d)),
deleteGoalType: (id) => req(`/goals/goal-types/${id}`, {method:'DELETE'}), deleteGoalType: (id) => req(`/goals/goal-types/${id}`, {method:'DELETE'}),
getSchemaInfo: () => req('/goals/schema-info'),
// Training Phases // Training Phases
listTrainingPhases: () => req('/goals/phases'), listTrainingPhases: () => req('/goals/phases'),