feat: dynamic schema dropdowns for goal type admin UI
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:
parent
210671059a
commit
2c978bf948
|
|
@ -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)
|
||||
# ============================================================================
|
||||
|
||||
@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")
|
||||
def list_goal_type_definitions(session: dict = Depends(require_auth)):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { api } from '../utils/api'
|
|||
|
||||
export default function AdminGoalTypesPage() {
|
||||
const [goalTypes, setGoalTypes] = useState([])
|
||||
const [schemaInfo, setSchemaInfo] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
|
@ -42,9 +43,14 @@ export default function AdminGoalTypesPage() {
|
|||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await api.listGoalTypeDefinitions()
|
||||
console.log('[DEBUG] Loaded goal types:', data)
|
||||
setGoalTypes(data || [])
|
||||
const [typesData, schema] = await Promise.all([
|
||||
api.listGoalTypeDefinitions(),
|
||||
api.getSchemaInfo()
|
||||
])
|
||||
console.log('[DEBUG] Loaded goal types:', typesData)
|
||||
console.log('[DEBUG] Loaded schema info:', schema)
|
||||
setGoalTypes(typesData || [])
|
||||
setSchemaInfo(schema || {})
|
||||
} catch (err) {
|
||||
console.error('[ERROR] Failed to load goal types:', err)
|
||||
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>
|
||||
<label className="form-label">Tabelle</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={formData.source_table}
|
||||
onChange={e => setFormData(f => ({ ...f, source_table: e.target.value }))}
|
||||
placeholder="z.B. meditation_log"
|
||||
/>
|
||||
{schemaInfo ? (
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={formData.source_table}
|
||||
onChange={e => setFormData(f => ({ ...f, source_table: e.target.value, source_column: '' }))}
|
||||
>
|
||||
<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>
|
||||
<label className="form-label">Spalte</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={formData.source_column}
|
||||
onChange={e => setFormData(f => ({ ...f, source_column: e.target.value }))}
|
||||
placeholder="z.B. duration_minutes"
|
||||
/>
|
||||
{schemaInfo && formData.source_table && schemaInfo[formData.source_table] ? (
|
||||
<select
|
||||
className="form-input"
|
||||
style={{ width: '100%' }}
|
||||
value={formData.source_column}
|
||||
onChange={e => setFormData(f => ({ ...f, source_column: e.target.value }))}
|
||||
>
|
||||
<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>
|
||||
|
||||
|
|
|
|||
|
|
@ -344,6 +344,7 @@ export const api = {
|
|||
createGoalType: (d) => req('/goals/goal-types', json(d)),
|
||||
updateGoalType: (id,d) => req(`/goals/goal-types/${id}`, jput(d)),
|
||||
deleteGoalType: (id) => req(`/goals/goal-types/${id}`, {method:'DELETE'}),
|
||||
getSchemaInfo: () => req('/goals/schema-info'),
|
||||
|
||||
// Training Phases
|
||||
listTrainingPhases: () => req('/goals/phases'),
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user