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
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:
parent
40a3b4b8e6
commit
ff8fd78a31
10
backend/migrations/044_training_unit_debrief_completed.sql
Normal file
10
backend/migrations/044_training_unit_debrief_completed.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
""",
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user