From 1923feb5bb8fa318fbb6be13df9e6f3e6a1ea360 Mon Sep 17 00:00:00 2001
From: Lars
Date: Tue, 5 May 2026 15:13:00 +0200
Subject: [PATCH] feat: enhance TrainingPlanningPage with calendar view and
date handling
- 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.
---
frontend/src/pages/TrainingPlanningPage.jsx | 380 ++++++++++++++++++--
1 file changed, 358 insertions(+), 22 deletions(-)
diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx
index dda19d7..15d7420 100644
--- a/frontend/src/pages/TrainingPlanningPage.jsx
+++ b/frontend/src/pages/TrainingPlanningPage.jsx
@@ -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 (Mo–So) */
+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 (
@@ -496,24 +609,110 @@ function TrainingPlanningPage() {
-
Von
-
setStartDate(e.target.value)}
- />
+
+ Ansicht
+
+
+ setPlanView('list')}
+ style={{ minWidth: '6.5rem' }}
+ >
+ Liste
+
+ {
+ 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
+
+
+
+ Liste: Zeitraum filtern · Kalender: Monatsraster aus Gruppenterminen (Mo–So)
+
-
- Bis
- setEndDate(e.target.value)}
- />
-
+ {planView === 'list' ? (
+ <>
+
+ Von
+ setStartDate(e.target.value)}
+ />
+
+
+
+ Bis
+ setEndDate(e.target.value)}
+ />
+
+ >
+ ) : (
+
+ setCalendarMonthStr((prev) => shiftCalendarMonth(prev, -1))}
+ >
+ ←
+
+
+ {calendarMonthTitle}
+
+ setCalendarMonthStr((prev) => shiftCalendarMonth(prev, 1))}
+ >
+ →
+
+ setCalendarMonthStr(new Date().toISOString().slice(0, 7))}
+ >
+ Aktueller Monat
+
+
+ )}
{selectedGroup && (
@@ -624,6 +823,143 @@ function TrainingPlanningPage() {
Wähle oben eine Trainingsgruppe — danach kannst du mit „Neue Trainingseinheit planen“ starten.
+ ) : planView === 'calendar' ? (
+
+ {units.length === 0 ? (
+
+ Im sichtbaren Monatsbereich liegt noch keine Einheit. Über + in einem Tag legst du einen
+ neuen Termin mit Datum an.
+
+ ) : null}
+
+ {WEEKDAYS_DE.map((w) => (
+
+ {w}
+
+ ))}
+ {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 (
+
+
+ {dayNum}
+ handleCreateForDate(dayIso)}
+ disabled={!selectedGroupId}
+ style={{ padding: '2px 6px', flexShrink: 0 }}
+ >
+ +
+
+
+
+ {dayUnits.slice(0, 3).map((unit) => (
+ 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)',
+ }}
+ >
+
+ {unit.planned_time_start
+ ? `${unit.planned_time_start.slice(0, 5)}`
+ : 'Ganztags'}
+
+ {unit.planned_focus?.trim() ? (
+
+ {(unit.planned_focus || '').trim().length > 24
+ ? `${(unit.planned_focus || '').trim().slice(0, 24)}…`
+ : unit.planned_focus}
+
+ ) : null}
+
+ ))}
+
+ {dayUnits.length > 3 ? (
+
+ +{dayUnits.length - 3} weitere
+
+ ) : null}
+
+ )
+ })}
+
+
) : units.length === 0 ? (