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.
This commit is contained in:
parent
d774d60a15
commit
1923feb5bb
|
|
@ -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 (
|
||||
<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 (Mo–So)
|
||||
</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' }}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user