From ff8fd78a310748ce73697caec9715003d984cfe3 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 7 May 2026 09:01:01 +0200 Subject: [PATCH] feat: add debriefing functionality and enhance training unit filtering - Introduced a new filter option for listing training units to show only those with pending debriefs. - Updated the dashboard to reflect changes in training unit statuses, renaming components for clarity. - Enhanced the Training Planning Page to manage debrief completion status, including UI elements for user interaction. - Improved API utility to support new filtering criteria for training units, ensuring accurate data retrieval. --- .../044_training_unit_debrief_completed.sql | 10 ++ backend/routers/training_planning.py | 18 +++- frontend/src/pages/Dashboard.jsx | 21 +++-- frontend/src/pages/TrainingPlanningPage.jsx | 92 ++++++++++++++++++- frontend/src/utils/api.js | 1 + 5 files changed, 127 insertions(+), 15 deletions(-) create mode 100644 backend/migrations/044_training_unit_debrief_completed.sql diff --git a/backend/migrations/044_training_unit_debrief_completed.sql b/backend/migrations/044_training_unit_debrief_completed.sql new file mode 100644 index 0000000..b34459f --- /dev/null +++ b/backend/migrations/044_training_unit_debrief_completed.sql @@ -0,0 +1,10 @@ +-- Rückschau / Nachbereitung: explizit abschließbar (Dashboard & Filter) +ALTER TABLE training_units + ADD COLUMN IF NOT EXISTS debrief_completed_at TIMESTAMPTZ NULL; + +COMMENT ON COLUMN training_units.debrief_completed_at IS + 'Zeitpunkt, zu dem die Trainer-Rückschau (Nachbereitung) bewusst abgeschlossen wurde; NULL = offen'; + +CREATE INDEX IF NOT EXISTS idx_training_units_debrief_open + ON training_units (status, debrief_completed_at) + WHERE status = 'completed' AND debrief_completed_at IS NULL; diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 1eddfea..c26c871 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -962,6 +962,10 @@ def list_training_units( end_date: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None), assigned_to_me: bool = Query(default=False), + debrief_pending: bool = Query( + default=False, + description="Nur abgeschlossene Einheiten ohne gesetzte Rückschau (debrief_completed_at IS NULL)", + ), sort: str = Query(default="desc"), limit: Optional[int] = Query(default=None), tenant: TenantContext = Depends(get_tenant_context), @@ -1081,7 +1085,11 @@ def list_training_units( where.append("tu.planned_date <= %s") params.append(end_date) - if status: + if debrief_pending: + where.append("tu.status = %s") + params.append("completed") + where.append("tu.debrief_completed_at IS NULL") + elif status: where.append("tu.status = %s") params.append(status) @@ -1384,6 +1392,13 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen assist_sql = ", assistant_trainer_profile_ids = %s" assist_params.append(na) + debrief_frag = "" + if "debrief_completed" in data and not is_blueprint: + if data.get("debrief_completed") is True: + debrief_frag = ", debrief_completed_at = NOW()" + else: + debrief_frag = ", debrief_completed_at = NULL" + cur.execute( f""" UPDATE training_units SET @@ -1402,6 +1417,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen updated_at = NOW() {lead_sql} {assist_sql} + {debrief_frag} WHERE id = %s """, ( diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 031231a..b393f43 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -42,7 +42,7 @@ function Dashboard() { setTrainingHomeErr(null) try { const today = new Date().toISOString().slice(0, 10) - const [upcomingRaw, recentRaw, plannedPool] = await Promise.all([ + const [upcomingRaw, reviewPendingRaw, plannedPool] = await Promise.all([ api.listTrainingUnits({ assigned_to_me: true, status: 'planned', @@ -52,9 +52,9 @@ function Dashboard() { }), api.listTrainingUnits({ assigned_to_me: true, - status: 'completed', + debrief_pending: true, sort: 'desc', - limit: 6, + limit: 8, }), api.listTrainingUnits({ assigned_to_me: true, @@ -72,7 +72,7 @@ function Dashboard() { if (!cancelled) { setTrainingHome({ upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [], - recent: Array.isArray(recentRaw) ? recentRaw : [], + reviewPending: Array.isArray(reviewPendingRaw) ? reviewPendingRaw : [], plannedWithNotes: noteHits, }) } @@ -315,14 +315,14 @@ function Dashboard() {
-

Rückschau

+

Offene Rückschau

{trainingHomeErr ? (

{trainingHomeErr}

- ) : trainingHome?.recent?.length ? ( + ) : trainingHome?.reviewPending?.length ? ( ) : ( -

Noch keine Einträge in der Kurzliste.

+

+ Keine durchgeführten Trainings mit offener Nachbereitung. Zum Abschluss der Rückschau in der + Planung „Rückschau erledigt“ aktivieren. +

)}
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index fec3178..793b781 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react' -import { Link } from 'react-router-dom' +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' +import { Link, useSearchParams } from 'react-router-dom' import api from '../utils/api' import { useAuth } from '../context/AuthContext' import ExercisePickerModal from '../components/ExercisePickerModal' @@ -112,6 +112,8 @@ function filterDirectoryExcludingLead(directory, excludeLeadPid) { } function TrainingPlanningPage() { const { user } = useAuth() + const [searchParams, setSearchParams] = useSearchParams() + const unitDeepLinkHandledRef = useRef(null) const [groups, setGroups] = useState([]) const [selectedGroupId, setSelectedGroupId] = useState('') const [units, setUnits] = useState([]) @@ -169,6 +171,7 @@ function TrainingPlanningPage() { status: 'planned', notes: '', trainer_notes: '', + debrief_completed: false, sections: [defaultSection()], ...sessionAssignDefaults() }) @@ -482,6 +485,7 @@ function TrainingPlanningPage() { status: 'planned', notes: '', trainer_notes: '', + debrief_completed: false, sections: [defaultSection('Hauptteil')], ...sessionAssignDefaults() }) @@ -510,6 +514,7 @@ function TrainingPlanningPage() { status: 'planned', notes: '', trainer_notes: '', + debrief_completed: false, sections: [defaultSection('Hauptteil')], ...sessionAssignDefaults() }) @@ -537,7 +542,7 @@ function TrainingPlanningPage() { } } - const handleEdit = async (unit) => { + const handleEdit = useCallback(async (unit) => { try { const fullUnit = await api.getTrainingUnit(unit.id) setEditingUnit(fullUnit) @@ -557,6 +562,7 @@ function TrainingPlanningPage() { status: fullUnit.status || 'planned', notes: fullUnit.notes || '', trainer_notes: fullUnit.trainer_notes || '', + debrief_completed: Boolean(fullUnit.debrief_completed_at), sections, lead_trainer_profile_id: fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== '' @@ -579,8 +585,46 @@ function TrainingPlanningPage() { setShowModal(true) } catch (err) { alert('Fehler beim Laden: ' + err.message) + throw err } - } + }, []) + + useEffect(() => { + if (!user?.id || loading) return + const uid = searchParams.get('unit') + if (!uid) { + unitDeepLinkHandledRef.current = null + return + } + if (unitDeepLinkHandledRef.current === uid) return + const idNum = parseInt(uid, 10) + if (!Number.isFinite(idNum)) return + unitDeepLinkHandledRef.current = uid + handleEdit({ id: idNum }) + .then(() => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + next.delete('unit') + next.delete('debrief') + return next + }, + { replace: true } + ) + }) + .catch(() => { + unitDeepLinkHandledRef.current = null + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev) + next.delete('unit') + next.delete('debrief') + return next + }, + { replace: true } + ) + }) + }, [user?.id, loading, searchParams, handleEdit, setSearchParams]) const handleSaveAsTemplate = async () => { const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):') @@ -691,6 +735,10 @@ function TrainingPlanningPage() { trainer_notes: formData.trainer_notes || null, sections: sectionsPayload } + if (editingUnit) { + payload.debrief_completed = + (formData.status || '') === 'completed' ? !!formData.debrief_completed : false + } const leadStr = String(formData.lead_trainer_profile_id || '').trim() if (leadStr) { payload.lead_trainer_profile_id = parseInt(leadStr, 10) @@ -725,7 +773,13 @@ function TrainingPlanningPage() { const updateFormField = (field, value) => { setFormData((prev) => { - if (field !== 'lead_trainer_profile_id') return { ...prev, [field]: value } + if (field !== 'lead_trainer_profile_id') { + const patch = { ...prev, [field]: value } + if (field === 'status' && value !== 'completed') { + patch.debrief_completed = false + } + return patch + } const ts = typeof value === 'string' ? value.trim() : String(value ?? '').trim() const strip = new Set() if (ts !== '') { @@ -2298,6 +2352,34 @@ function TrainingPlanningPage() { + + {formData.status === 'completed' ? ( +
+ +
+ ) : null} )} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 447a7d1..b44b5b7 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -996,6 +996,7 @@ export async function listTrainingUnits(filters = {}) { if (filters.start_date) q.set('start_date', filters.start_date) if (filters.end_date) q.set('end_date', filters.end_date) if (filters.status) q.set('status', filters.status) + if (filters.debrief_pending === true) q.set('debrief_pending', 'true') if (filters.assigned_to_me === true) q.set('assigned_to_me', 'true') if (filters.sort) q.set('sort', String(filters.sort)) if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit))