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),
|
end_date: Optional[str] = Query(default=None),
|
||||||
status: Optional[str] = Query(default=None),
|
status: Optional[str] = Query(default=None),
|
||||||
assigned_to_me: bool = Query(default=False),
|
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"),
|
sort: str = Query(default="desc"),
|
||||||
limit: Optional[int] = Query(default=None),
|
limit: Optional[int] = Query(default=None),
|
||||||
tenant: TenantContext = Depends(get_tenant_context),
|
tenant: TenantContext = Depends(get_tenant_context),
|
||||||
|
|
@ -1081,7 +1085,11 @@ def list_training_units(
|
||||||
where.append("tu.planned_date <= %s")
|
where.append("tu.planned_date <= %s")
|
||||||
params.append(end_date)
|
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")
|
where.append("tu.status = %s")
|
||||||
params.append(status)
|
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_sql = ", assistant_trainer_profile_ids = %s"
|
||||||
assist_params.append(na)
|
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(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
UPDATE training_units SET
|
UPDATE training_units SET
|
||||||
|
|
@ -1402,6 +1417,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
{lead_sql}
|
{lead_sql}
|
||||||
{assist_sql}
|
{assist_sql}
|
||||||
|
{debrief_frag}
|
||||||
WHERE id = %s
|
WHERE id = %s
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ function Dashboard() {
|
||||||
setTrainingHomeErr(null)
|
setTrainingHomeErr(null)
|
||||||
try {
|
try {
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
const [upcomingRaw, recentRaw, plannedPool] = await Promise.all([
|
const [upcomingRaw, reviewPendingRaw, plannedPool] = await Promise.all([
|
||||||
api.listTrainingUnits({
|
api.listTrainingUnits({
|
||||||
assigned_to_me: true,
|
assigned_to_me: true,
|
||||||
status: 'planned',
|
status: 'planned',
|
||||||
|
|
@ -52,9 +52,9 @@ function Dashboard() {
|
||||||
}),
|
}),
|
||||||
api.listTrainingUnits({
|
api.listTrainingUnits({
|
||||||
assigned_to_me: true,
|
assigned_to_me: true,
|
||||||
status: 'completed',
|
debrief_pending: true,
|
||||||
sort: 'desc',
|
sort: 'desc',
|
||||||
limit: 6,
|
limit: 8,
|
||||||
}),
|
}),
|
||||||
api.listTrainingUnits({
|
api.listTrainingUnits({
|
||||||
assigned_to_me: true,
|
assigned_to_me: true,
|
||||||
|
|
@ -72,7 +72,7 @@ function Dashboard() {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setTrainingHome({
|
setTrainingHome({
|
||||||
upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [],
|
upcoming: Array.isArray(upcomingRaw) ? upcomingRaw : [],
|
||||||
recent: Array.isArray(recentRaw) ? recentRaw : [],
|
reviewPending: Array.isArray(reviewPendingRaw) ? reviewPendingRaw : [],
|
||||||
plannedWithNotes: noteHits,
|
plannedWithNotes: noteHits,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -315,14 +315,14 @@ function Dashboard() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card dashboard-preview-card">
|
<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 ? (
|
{trainingHomeErr ? (
|
||||||
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
|
<p className="dashboard-preview-card__err">{trainingHomeErr}</p>
|
||||||
) : trainingHome?.recent?.length ? (
|
) : trainingHome?.reviewPending?.length ? (
|
||||||
<ul className="dashboard-preview-card__list">
|
<ul className="dashboard-preview-card__list">
|
||||||
{trainingHome.recent.map((u) => (
|
{trainingHome.reviewPending.map((u) => (
|
||||||
<li key={`r-${u.id}`}>
|
<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'}
|
{(u.actual_date || u.planned_date || '').toString().slice(0, 10) || 'Datum'}
|
||||||
</Link>
|
</Link>
|
||||||
{u.group_name ? (
|
{u.group_name ? (
|
||||||
|
|
@ -332,7 +332,10 @@ function Dashboard() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||||
|
|
@ -112,6 +112,8 @@ function filterDirectoryExcludingLead(directory, excludeLeadPid) {
|
||||||
}
|
}
|
||||||
function TrainingPlanningPage() {
|
function TrainingPlanningPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const unitDeepLinkHandledRef = useRef(null)
|
||||||
const [groups, setGroups] = useState([])
|
const [groups, setGroups] = useState([])
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState('')
|
const [selectedGroupId, setSelectedGroupId] = useState('')
|
||||||
const [units, setUnits] = useState([])
|
const [units, setUnits] = useState([])
|
||||||
|
|
@ -169,6 +171,7 @@ function TrainingPlanningPage() {
|
||||||
status: 'planned',
|
status: 'planned',
|
||||||
notes: '',
|
notes: '',
|
||||||
trainer_notes: '',
|
trainer_notes: '',
|
||||||
|
debrief_completed: false,
|
||||||
sections: [defaultSection()],
|
sections: [defaultSection()],
|
||||||
...sessionAssignDefaults()
|
...sessionAssignDefaults()
|
||||||
})
|
})
|
||||||
|
|
@ -482,6 +485,7 @@ function TrainingPlanningPage() {
|
||||||
status: 'planned',
|
status: 'planned',
|
||||||
notes: '',
|
notes: '',
|
||||||
trainer_notes: '',
|
trainer_notes: '',
|
||||||
|
debrief_completed: false,
|
||||||
sections: [defaultSection('Hauptteil')],
|
sections: [defaultSection('Hauptteil')],
|
||||||
...sessionAssignDefaults()
|
...sessionAssignDefaults()
|
||||||
})
|
})
|
||||||
|
|
@ -510,6 +514,7 @@ function TrainingPlanningPage() {
|
||||||
status: 'planned',
|
status: 'planned',
|
||||||
notes: '',
|
notes: '',
|
||||||
trainer_notes: '',
|
trainer_notes: '',
|
||||||
|
debrief_completed: false,
|
||||||
sections: [defaultSection('Hauptteil')],
|
sections: [defaultSection('Hauptteil')],
|
||||||
...sessionAssignDefaults()
|
...sessionAssignDefaults()
|
||||||
})
|
})
|
||||||
|
|
@ -537,7 +542,7 @@ function TrainingPlanningPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = async (unit) => {
|
const handleEdit = useCallback(async (unit) => {
|
||||||
try {
|
try {
|
||||||
const fullUnit = await api.getTrainingUnit(unit.id)
|
const fullUnit = await api.getTrainingUnit(unit.id)
|
||||||
setEditingUnit(fullUnit)
|
setEditingUnit(fullUnit)
|
||||||
|
|
@ -557,6 +562,7 @@ function TrainingPlanningPage() {
|
||||||
status: fullUnit.status || 'planned',
|
status: fullUnit.status || 'planned',
|
||||||
notes: fullUnit.notes || '',
|
notes: fullUnit.notes || '',
|
||||||
trainer_notes: fullUnit.trainer_notes || '',
|
trainer_notes: fullUnit.trainer_notes || '',
|
||||||
|
debrief_completed: Boolean(fullUnit.debrief_completed_at),
|
||||||
sections,
|
sections,
|
||||||
lead_trainer_profile_id:
|
lead_trainer_profile_id:
|
||||||
fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== ''
|
fullUnit.lead_trainer_profile_id != null && fullUnit.lead_trainer_profile_id !== ''
|
||||||
|
|
@ -579,8 +585,46 @@ function TrainingPlanningPage() {
|
||||||
setShowModal(true)
|
setShowModal(true)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Fehler beim Laden: ' + err.message)
|
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 handleSaveAsTemplate = async () => {
|
||||||
const name = window.prompt('Name für die neue Trainingsvorlage (nur Abschnitts-Gliederung):')
|
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,
|
trainer_notes: formData.trainer_notes || null,
|
||||||
sections: sectionsPayload
|
sections: sectionsPayload
|
||||||
}
|
}
|
||||||
|
if (editingUnit) {
|
||||||
|
payload.debrief_completed =
|
||||||
|
(formData.status || '') === 'completed' ? !!formData.debrief_completed : false
|
||||||
|
}
|
||||||
const leadStr = String(formData.lead_trainer_profile_id || '').trim()
|
const leadStr = String(formData.lead_trainer_profile_id || '').trim()
|
||||||
if (leadStr) {
|
if (leadStr) {
|
||||||
payload.lead_trainer_profile_id = parseInt(leadStr, 10)
|
payload.lead_trainer_profile_id = parseInt(leadStr, 10)
|
||||||
|
|
@ -725,7 +773,13 @@ function TrainingPlanningPage() {
|
||||||
|
|
||||||
const updateFormField = (field, value) => {
|
const updateFormField = (field, value) => {
|
||||||
setFormData((prev) => {
|
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 ts = typeof value === 'string' ? value.trim() : String(value ?? '').trim()
|
||||||
const strip = new Set()
|
const strip = new Set()
|
||||||
if (ts !== '') {
|
if (ts !== '') {
|
||||||
|
|
@ -2298,6 +2352,34 @@ function TrainingPlanningPage() {
|
||||||
<option value="cancelled">Abgesagt</option>
|
<option value="cancelled">Abgesagt</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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.start_date) q.set('start_date', filters.start_date)
|
||||||
if (filters.end_date) q.set('end_date', filters.end_date)
|
if (filters.end_date) q.set('end_date', filters.end_date)
|
||||||
if (filters.status) q.set('status', filters.status)
|
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.assigned_to_me === true) q.set('assigned_to_me', 'true')
|
||||||
if (filters.sort) q.set('sort', String(filters.sort))
|
if (filters.sort) q.set('sort', String(filters.sort))
|
||||||
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit))
|
if (filters.limit != null && filters.limit !== '') q.set('limit', String(filters.limit))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user