feat: enhance TrainingPlanningPage with calendar view and date handling
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 47s

- Introduced a calendar view option for training planning, allowing users to switch between list and calendar layouts.
- Added functions for managing calendar grid ranges and enumerating ISO days, improving date handling.
- Updated state management to include calendar month and view type, enhancing user experience.
- Refactored loadUnits function to adapt to the selected view, ensuring accurate data loading based on the chosen layout.
This commit is contained in:
Lars 2026-05-05 15:13:00 +02:00
parent d774d60a15
commit 1923feb5bb

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react'
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
@ -19,6 +19,59 @@ function addDaysIsoDate(isoDay, daysDelta) {
return d.toISOString().slice(0, 10)
}
function pad2(n) {
return String(n).padStart(2, '0')
}
function toIsoLocal(d) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
}
/** Montag = erster Wochentag (ISO-Woche UI) */
function mondayIndex(d) {
return (d.getDay() + 6) % 7
}
/** Kalendarische Monatsansicht: erster und letzter Tag des sichtbaren Rasters (MoSo) */
function getCalendarGridRange(ym) {
const parts = (ym || '').split('-').map(Number)
const y = parts[0]
const m = parts[1]
if (!y || !m || m < 1 || m > 12) {
const t = new Date()
return { gridStart: toIsoLocal(t), gridEnd: toIsoLocal(t) }
}
const first = new Date(y, m - 1, 1)
const last = new Date(y, m, 0)
const gridStart = new Date(first)
gridStart.setDate(first.getDate() - mondayIndex(first))
const lastMon = mondayIndex(last)
const gridEnd = new Date(last)
gridEnd.setDate(last.getDate() + (6 - lastMon))
return { gridStart: toIsoLocal(gridStart), gridEnd: toIsoLocal(gridEnd) }
}
function shiftCalendarMonth(ym, delta) {
const parts = (ym || '').split('-').map(Number)
const y = parts[0] || new Date().getFullYear()
const m = parts[1] || 1
const d = new Date(y, m - 1 + delta, 1)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`
}
function enumerateIsoDays(fromIso, toIso) {
const out = []
const cur = new Date(`${fromIso}T12:00:00`)
const end = new Date(`${toIso}T12:00:00`)
while (cur <= end) {
out.push(toIsoLocal(cur))
cur.setDate(cur.getDate() + 1)
}
return out
}
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
function TrainingPlanningPage() {
const { user } = useAuth()
const [groups, setGroups] = useState([])
@ -50,6 +103,8 @@ function TrainingPlanningPage() {
const [startDate, setStartDate] = useState(today)
const [endDate, setEndDate] = useState(thirtyDaysLater)
const [planView, setPlanView] = useState('list')
const [calendarMonthStr, setCalendarMonthStr] = useState(() => today.slice(0, 7))
const [formData, setFormData] = useState({
group_id: '',
@ -75,7 +130,7 @@ function TrainingPlanningPage() {
if (selectedGroupId) {
loadUnits()
}
}, [selectedGroupId, startDate, endDate])
}, [selectedGroupId, loadUnits])
useEffect(() => {
if (!frameworkImportOpen) return
@ -239,19 +294,26 @@ function TrainingPlanningPage() {
}
}
const loadUnits = async () => {
const loadUnits = useCallback(async () => {
if (!selectedGroupId) return
let start = startDate
let end = endDate
if (planView === 'calendar') {
const r = getCalendarGridRange(calendarMonthStr)
start = r.gridStart
end = r.gridEnd
}
try {
const unitsData = await api.listTrainingUnits({
group_id: selectedGroupId,
start_date: startDate,
end_date: endDate
start_date: start,
end_date: end
})
setUnits(unitsData)
} catch (err) {
console.error('Failed to load units:', err)
}
}
}, [selectedGroupId, startDate, endDate, planView, calendarMonthStr])
const handleQuickCreate = async () => {
if (!selectedGroupId) {
@ -301,6 +363,32 @@ function TrainingPlanningPage() {
setShowModal(true)
}
const handleCreateForDate = (isoDay) => {
if (!selectedGroupId) {
alert('Bitte wähle zuerst eine Trainingsgruppe')
return
}
const group = groups.find((g) => g.id === parseInt(selectedGroupId, 10))
setEditingUnit(null)
setDraftPlanTemplateId('')
setFormData({
group_id: selectedGroupId,
planned_date: isoDay,
planned_time_start: group?.time_start?.slice(0, 5) || '',
planned_time_end: group?.time_end?.slice(0, 5) || '',
planned_focus: '',
actual_date: '',
actual_time_start: '',
actual_time_end: '',
attendance_count: '',
status: 'planned',
notes: '',
trainer_notes: '',
sections: [defaultSection('Hauptteil')]
})
setShowModal(true)
}
const applyTemplateFromSelect = async (templateId) => {
setDraftPlanTemplateId(templateId)
if (!templateId) return
@ -422,6 +510,31 @@ function TrainingPlanningPage() {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const calendarGridDays = useMemo(() => {
const r = getCalendarGridRange(calendarMonthStr)
return enumerateIsoDays(r.gridStart, r.gridEnd)
}, [calendarMonthStr])
const unitsByPlannedDate = useMemo(() => {
const m = new Map()
for (const u of units) {
const raw = u.planned_date
if (!raw) continue
const key = String(raw).slice(0, 10)
if (!m.has(key)) m.set(key, [])
m.get(key).push(u)
}
return m
}, [units])
const calendarMonthTitle = useMemo(() => {
const p = calendarMonthStr.split('-').map(Number)
const y = p[0]
const mo = p[1]
if (!y || !mo) return ''
return new Date(y, mo - 1, 1).toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
}, [calendarMonthStr])
if (loading) {
return (
<div className="app-page" style={{ padding: '2rem 0', textAlign: 'center' }}>
@ -496,24 +609,110 @@ function TrainingPlanningPage() {
</div>
<div>
<label className="form-label">Von</label>
<input
type="date"
className="form-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<span className="form-label" style={{ display: 'block' }}>
Ansicht
</span>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '4px' }}>
<button
type="button"
className={planView === 'list' ? 'btn btn-primary' : 'btn btn-secondary'}
aria-pressed={planView === 'list'}
onClick={() => setPlanView('list')}
style={{ minWidth: '6.5rem' }}
>
Liste
</button>
<button
type="button"
className={planView === 'calendar' ? 'btn btn-primary' : 'btn btn-secondary'}
aria-pressed={planView === 'calendar'}
onClick={() => {
setPlanView('calendar')
setCalendarMonthStr((prev) => {
const fromList = (startDate || '').slice(0, 7)
if (/^\d{4}-\d{2}$/.test(fromList)) return fromList
return prev || new Date().toISOString().slice(0, 7)
})
}}
style={{ minWidth: '6.5rem' }}
>
Kalender
</button>
</div>
<p style={{ fontSize: '0.75rem', color: 'var(--text3)', marginTop: '0.35rem', marginBottom: 0 }}>
Liste: Zeitraum filtern · Kalender: Monatsraster aus Gruppenterminen (MoSo)
</p>
</div>
<div>
<label className="form-label">Bis</label>
<input
type="date"
className="form-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
{planView === 'list' ? (
<>
<div>
<label className="form-label">Von</label>
<input
type="date"
className="form-input"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div>
<label className="form-label">Bis</label>
<input
type="date"
className="form-input"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
</>
) : (
<div
style={{
gridColumn: '1 / -1',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '0.65rem',
}}
>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
aria-label="Voriger Monat"
onClick={() => setCalendarMonthStr((prev) => shiftCalendarMonth(prev, -1))}
>
</button>
<span
style={{
fontWeight: 600,
fontSize: '1rem',
flex: '1 1 auto',
textAlign: 'center',
minWidth: '12rem',
}}
>
{calendarMonthTitle}
</span>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
aria-label="Nächster Monat"
onClick={() => setCalendarMonthStr((prev) => shiftCalendarMonth(prev, 1))}
>
</button>
<button
type="button"
className="btn btn-secondary"
style={{ whiteSpace: 'nowrap', padding: '0.35rem 0.65rem', fontSize: '0.85rem' }}
onClick={() => setCalendarMonthStr(new Date().toISOString().slice(0, 7))}
>
Aktueller Monat
</button>
</div>
)}
</div>
{selectedGroup && (
@ -624,6 +823,143 @@ function TrainingPlanningPage() {
Wähle oben eine Trainingsgruppe danach kannst du mit <strong>Neue Trainingseinheit planen</strong> starten.
</p>
</div>
) : planView === 'calendar' ? (
<div className="card" style={{ padding: 'clamp(8px, 2vw, 14px)', overflowX: 'auto' }}>
{units.length === 0 ? (
<p style={{ color: 'var(--text2)', fontSize: '0.88rem', margin: '0 0 12px', lineHeight: 1.45 }}>
Im sichtbaren Monatsbereich liegt noch keine Einheit. Über <strong>+</strong> in einem Tag legst du einen
neuen Termin mit Datum an.
</p>
) : null}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, minmax(0, 1fr))',
gap: '4px',
minWidth: 'min(960px, 100%)',
}}
role="grid"
aria-label={`Kalender ${calendarMonthTitle}`}
>
{WEEKDAYS_DE.map((w) => (
<div
key={w}
style={{
fontWeight: 600,
fontSize: '0.72rem',
textAlign: 'center',
padding: '6px 2px',
color: 'var(--text3)',
}}
role="columnheader"
>
{w}
</div>
))}
{calendarGridDays.map((dayIso) => {
const inMonth = dayIso.slice(0, 7) === calendarMonthStr
const dayNum = parseInt(dayIso.slice(8, 10), 10)
const isTodayMarker = dayIso === today
const dayUnits = unitsByPlannedDate.get(dayIso) || []
return (
<div
key={dayIso}
role="gridcell"
style={{
minHeight: '96px',
border: '1px solid var(--border, rgba(0, 0, 0, 0.1))',
borderRadius: '6px',
padding: '5px',
background: inMonth ? 'var(--surface)' : 'var(--surface2)',
opacity: inMonth ? 1 : 0.58,
boxShadow: isTodayMarker ? 'inset 0 0 0 2px var(--accent-dark, #256)' : undefined,
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '4px',
marginBottom: '6px',
}}
>
<span style={{ fontWeight: 700, fontSize: '0.82rem', lineHeight: 1 }}>{dayNum}</span>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
title={`Neue Trainingseinheit am ${dayIso}`}
aria-label={`Neue Trainingseinheit am ${dayIso}`}
onClick={() => handleCreateForDate(dayIso)}
disabled={!selectedGroupId}
style={{ padding: '2px 6px', flexShrink: 0 }}
>
+
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
{dayUnits.slice(0, 3).map((unit) => (
<button
key={unit.id}
type="button"
onClick={() => handleEdit(unit)}
title={[
unit.planned_time_start?.slice(0, 5) || '',
unit.planned_focus?.trim(),
unit.status === 'completed'
? 'Durchgeführt'
: unit.status === 'cancelled'
? 'Abgesagt'
: 'Geplant',
]
.filter(Boolean)
.join(' · ')}
style={{
border: 'none',
cursor: 'pointer',
textAlign: 'left',
padding: '4px 5px',
borderRadius: '4px',
fontSize: '0.7rem',
lineHeight: 1.25,
width: '100%',
borderLeftWidth: '3px',
borderLeftStyle: 'solid',
borderLeftColor:
unit.status === 'completed'
? '#2ea44f'
: unit.status === 'cancelled'
? 'var(--danger)'
: 'var(--accent-dark)',
background: 'var(--surface2)',
color: 'var(--text1)',
}}
>
<span style={{ fontWeight: 600 }}>
{unit.planned_time_start
? `${unit.planned_time_start.slice(0, 5)}`
: 'Ganztags'}
</span>
{unit.planned_focus?.trim() ? (
<span style={{ display: 'block', color: 'var(--text2)', fontWeight: 400 }}>
{(unit.planned_focus || '').trim().length > 24
? `${(unit.planned_focus || '').trim().slice(0, 24)}`
: unit.planned_focus}
</span>
) : null}
</button>
))}
</div>
{dayUnits.length > 3 ? (
<p style={{ fontSize: '0.65rem', color: 'var(--text3)', margin: '4px 0 0', lineHeight: 1.3 }}>
+{dayUnits.length - 3} weitere
</p>
) : null}
</div>
)
})}
</div>
</div>
) : units.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>