feat: add debriefing functionality and enhance training unit filtering
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s

- 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.
This commit is contained in:
Lars 2026-05-07 09:01:01 +02:00
parent 40a3b4b8e6
commit ff8fd78a31
5 changed files with 127 additions and 15 deletions

View File

@ -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;

View File

@ -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
""",
(

View File

@ -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() {
</div>
<div className="card dashboard-preview-card">
<h3 className="dashboard-preview-card__title">Rückschau</h3>
<h3 className="dashboard-preview-card__title">Offene Rückschau</h3>
{trainingHomeErr ? (
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
) : trainingHome?.recent?.length ? (
) : trainingHome?.reviewPending?.length ? (
<ul className="dashboard-preview-card__list">
{trainingHome.recent.map((u) => (
{trainingHome.reviewPending.map((u) => (
<li key={`r-${u.id}`}>
<Link to={`/planning/run/${u.id}`} className="dashboard-preview-card__link">
<Link to={`/planning?unit=${u.id}`} className="dashboard-preview-card__link">
{(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'}
</Link>
{u.group_name ? (
@ -332,7 +332,10 @@ function Dashboard() {
))}
</ul>
) : (
<p className="dashboard-preview-card__empty">Noch keine Einträge in der Kurzliste.</p>
<p className="dashboard-preview-card__empty">
Keine durchgeführten Trainings mit offener Nachbereitung. Zum Abschluss der Rückschau in der
Planung Rückschau erledigt aktivieren.
</p>
)}
</div>
</div>

View File

@ -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() {
<option value="cancelled">Abgesagt</option>
</select>
</div>
{formData.status === 'completed' ? (
<div className="form-row" style={{ marginTop: '0.75rem' }}>
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '10px',
cursor: 'pointer',
lineHeight: 1.45,
}}
>
<input
type="checkbox"
checked={!!formData.debrief_completed}
onChange={(e) => updateFormField('debrief_completed', e.target.checked)}
style={{ marginTop: '3px' }}
/>
<span>
<strong>Rückschau erledigt</strong>
<span className="muted" style={{ display: 'block', fontSize: '0.82rem', marginTop: '5px' }}>
Wenn angehakt, erscheint die Einheit nicht mehr unter Offene Rückschau auf dem
Dashboard (Nachbereitung gilt als abgeschlossen).
</span>
</span>
</label>
</div>
) : null}
</>
)}

View File

@ -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))