diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index e354d89..6894559 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -621,16 +621,35 @@ def replace_activity_session_metrics( return fetch_activity_session_metrics(cur, activity_log_id) -def get_activity_session_logical_unit(cur, profile_id: str, activity_log_id: str) -> Dict[str, Any]: +def get_activity_session_logical_unit( + cur, + profile_id: str, + activity_log_id: str, + *, + use_form_training_context: bool = False, + form_training_category: Optional[str] = None, + form_training_type_id: Optional[int] = None, +) -> Dict[str, Any]: cur.execute("SELECT * FROM activity_log WHERE id = %s", (activity_log_id,)) row = cur.fetchone() if not row or str(row["profile_id"]) != str(profile_id): raise ActivitySessionMetricsError(404, "Aktivität nicht gefunden") header = dict(row) - schema = resolve_activity_attribute_schema( - cur, header.get("training_category"), header.get("training_type_id") - ) + if use_form_training_context: + cat = form_training_category + if isinstance(cat, str): + cat = cat.strip() or None + tid = form_training_type_id + else: + cat = header.get("training_category") + tid = header.get("training_type_id") + if tid is not None: + try: + tid = int(tid) + except (TypeError, ValueError): + tid = None + schema = resolve_activity_attribute_schema(cur, cat, tid) metrics = fetch_activity_session_metrics(cur, activity_log_id) merged_metrics = merge_column_backed_and_eav_metrics(header, schema, metrics) return { diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 4be8cc1..da3e3ef 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -371,6 +371,25 @@ def get_activity_mappable_fields(session: dict = Depends(require_auth)): return get_mappable_activity_field_catalog(cur, pid) +@router.get("/attribute-schema") +def get_activity_attribute_schema( + training_category: Optional[str] = Query(None), + training_type_id: Optional[int] = Query(None), + session: dict = Depends(require_auth), +): + """ + Aufgelöstes Attributprofil (tcp/ttp) für Erfassung ohne bestehende Session — + gleiche Logik wie resolve_activity_attribute_schema. + """ + from data_layer.activity_session_metrics import resolve_activity_attribute_schema + + cat = (training_category or "").strip() or None + with get_db() as conn: + cur = get_cursor(conn) + schema = resolve_activity_attribute_schema(cur, cat, training_type_id) + return {"schema": schema} + + @router.put("/{eid}") def update_activity(eid: str, e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Update existing activity entry.""" @@ -422,6 +441,12 @@ def replace_activity_metrics( @router.get("/{eid}") def get_activity_session( eid: str, + use_form_schema: bool = Query( + False, + description="True: Schema aus Query training_category / training_type_id (Formular), nicht nur DB-Zeile", + ), + training_category: Optional[str] = Query(None), + training_type_id: Optional[int] = Query(None), session: dict = Depends(require_auth), ): """Session-Kopf + aufgelöstes Schema + EAV-Metriken (Layer 1).""" @@ -435,7 +460,14 @@ def get_activity_session( try: with get_db() as conn: cur = get_cursor(conn) - unit = get_activity_session_logical_unit(cur, pid, eid) + unit = get_activity_session_logical_unit( + cur, + pid, + eid, + use_form_training_context=use_form_schema, + form_training_category=training_category, + form_training_type_id=training_type_id, + ) except ActivitySessionMetricsError as err: raise HTTPException(err.status_code, err.detail) from err unit["header"] = serialize_dates(unit["header"]) diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index 163857c..45a872c 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -554,6 +554,12 @@ export default function ActivityPage() { const [categories, setCategories] = useState({}) // v9d: Training categories const [sessionDetail, setSessionDetail] = useState(null) const [metricDraft, setMetricDraft] = useState({}) + /** Beim Wechsel Kategorie/Typ: Nutzerwerte für weiterhin vorhandene Schema-Keys nicht mit Server überschreiben */ + const editSchemaKeysPrevRef = useRef(new Set()) + const prevEditingIdRef = useRef(null) + const [manualSchema, setManualSchema] = useState(null) + const [manualMetricDraft, setManualMetricDraft] = useState({}) + const manualSchemaKeysPrevRef = useRef(new Set()) const [sessionLoadError, setSessionLoadError] = useState(null) const [savingEdit, setSavingEdit] = useState(false) const [listLoadingMore, setListLoadingMore] = useState(false) @@ -639,18 +645,31 @@ export default function ActivityPage() { api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err)) }, [fetchMonthsChain]) + useEffect(() => { + editSchemaKeysPrevRef.current = new Set() + }, [editing?.id]) + useEffect(() => { if (!editing?.id) { setSessionDetail(null) setMetricDraft({}) setSessionLoadError(null) + prevEditingIdRef.current = null return } let cancelled = false setSessionLoadError(null) + if (prevEditingIdRef.current !== editing.id) { + setSessionDetail(null) + prevEditingIdRef.current = editing.id + } ;(async () => { try { - const d = await api.getActivitySession(editing.id) + const d = await api.getActivitySession(editing.id, { + useFormSchema: true, + training_category: editing.training_category, + training_type_id: editing.training_type_id, + }) if (!cancelled) setSessionDetail(d) } catch (err) { if (!cancelled) { @@ -660,25 +679,89 @@ export default function ActivityPage() { } })() return () => { cancelled = true } - }, [editing?.id]) + }, [editing?.id, editing?.training_category, editing?.training_type_id]) useEffect(() => { if (!sessionDetail) { setMetricDraft({}) return } - const m = {} - for (const row of sessionDetail.metrics || []) { - m[row.key] = row.value - } - for (const s of sessionDetail.schema || []) { - if (!(s.key in m)) { - m[s.key] = s.data_type === 'boolean' ? false : '' + const newKeys = new Set((sessionDetail.schema || []).map((s) => s.key)) + const oldKeys = editSchemaKeysPrevRef.current + + setMetricDraft((prev) => { + const next = { ...prev } + for (const row of sessionDetail.metrics || []) { + const k = row.key + if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) { + continue + } + next[k] = row.value } - } - setMetricDraft(m) + for (const s of sessionDetail.schema || []) { + if (!(s.key in next)) { + next[s.key] = s.data_type === 'boolean' ? false : '' + } + } + return next + }) + editSchemaKeysPrevRef.current = newKeys }, [sessionDetail]) + useEffect(() => { + if (tab !== 'add') { + setManualSchema(null) + setManualMetricDraft({}) + manualSchemaKeysPrevRef.current = new Set() + return + } + const tid = form.training_type_id + const cat = form.training_category + if (tid == null && (cat == null || cat === '')) { + setManualSchema(null) + setManualMetricDraft({}) + manualSchemaKeysPrevRef.current = new Set() + return + } + let cancelled = false + ;(async () => { + try { + const r = await api.getActivityAttributeSchema({ + training_category: cat || undefined, + training_type_id: tid ?? undefined, + }) + if (!cancelled) setManualSchema(Array.isArray(r.schema) ? r.schema : []) + } catch (err) { + console.error('attribute-schema:', err) + if (!cancelled) setManualSchema([]) + } + })() + return () => { cancelled = true } + }, [tab, form.training_category, form.training_type_id]) + + useEffect(() => { + if (tab !== 'add' || !manualSchema) { + return + } + const newKeys = new Set(manualSchema.map((s) => s.key)) + const oldKeys = manualSchemaKeysPrevRef.current + + setManualMetricDraft((prev) => { + const next = { ...prev } + for (const s of manualSchema) { + const k = s.key + if (oldKeys.size > 0 && oldKeys.has(k) && newKeys.has(k) && k in prev) { + continue + } + if (!(k in next)) { + next[k] = s.data_type === 'boolean' ? false : '' + } + } + return next + }) + manualSchemaKeysPrevRef.current = newKeys + }, [tab, manualSchema]) + const handleSave = async () => { setSaving(true) setError(null) @@ -698,7 +781,20 @@ export default function ActivityPage() { if(payload.hr_max) payload.hr_max = parseFloat(payload.hr_max) if(payload.rpe) payload.rpe = parseInt(payload.rpe) payload.source = 'manual' - await api.createActivity(payload) + const created = await api.createActivity(payload) + if (manualSchema && manualSchema.length > 0 && created?.id) { + try { + const metrics = buildMetricsPayload(manualSchema, manualMetricDraft) + await api.putActivityMetrics(created.id, { metrics }) + } catch (metErr) { + console.error(metErr) + setError( + metErr.message || + 'Eintrag gespeichert, aber Zusatzfelder konnten nicht gespeichert werden.', + ) + setTimeout(() => setError(null), 8000) + } + } setSaved(true) await load() await loadUsage() // Reload usage after save @@ -864,9 +960,23 @@ export default function ActivityPage() { Training eintragen {activityUsage && } - + + } + /> )} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 68d0f89..c52e032 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -352,7 +352,39 @@ export const api = { adminDeleteTrainingTypeParameter: (id) => req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }), - getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`), + /** + * @param {string} id + * @param {{ useFormSchema?: boolean, training_category?: string | null, training_type_id?: number | null }} [opts] + */ + getActivitySession: (id, opts = {}) => { + const q = new URLSearchParams() + if (opts.useFormSchema) { + q.set('use_form_schema', 'true') + if (opts.training_category != null && opts.training_category !== '') { + q.set('training_category', String(opts.training_category)) + } + if (opts.training_type_id != null && opts.training_type_id !== '') { + q.set('training_type_id', String(opts.training_type_id)) + } + } + const qs = q.toString() + return req(`/activity/${encodeURIComponent(id)}${qs ? `?${qs}` : ''}`) + }, + /** + * Attributprofil ohne Session (manuelle Erfassung / Vorschau). + * @param {{ training_category?: string | null, training_type_id?: number | null }} [params] + */ + getActivityAttributeSchema: (params = {}) => { + const q = new URLSearchParams() + if (params.training_category != null && params.training_category !== '') { + q.set('training_category', String(params.training_category)) + } + if (params.training_type_id != null && params.training_type_id !== '') { + q.set('training_type_id', String(params.training_type_id)) + } + const qs = q.toString() + return req(`/activity/attribute-schema${qs ? `?${qs}` : ''}`) + }, putActivityMetrics: (id, body) => req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)),