shinkan-jinkendo/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx
Lars 49adb395dd
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 1m15s
feat(version): bump to 0.8.110 and update project specifications
- Updated app version to 0.8.110 and database schema version to 20260512057, reflecting recent enhancements.
- Revised project status documentation to include new versioning and next steps for development.
- Enhanced the functional specification for training modules and combination exercises, detailing upcoming features and improvements.
- Improved technical specifications to align with the latest code changes, ensuring consistency across documentation.
- Introduced new UI elements for toast notifications and unsaved changes prompts to enhance user experience.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:34:38 +02:00

213 lines
7.8 KiB
JavaScript

import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
function dashIfEmpty(val) {
const s = (val ?? '').toString().trim()
return s.length ? s : '—'
}
function FrameworkSummaryMeta({ r }) {
const trainingTypes =
typeof r.training_type_names_agg === 'string' ? r.training_type_names_agg.trim() : ''
const targetGroups =
typeof r.target_group_names_agg === 'string' ? r.target_group_names_agg.trim() : ''
const styleDir = typeof r.style_direction_name === 'string' ? r.style_direction_name.trim() : ''
const focus = typeof r.focus_area_name === 'string' ? r.focus_area_name.trim() : ''
const rowStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(6.5rem, 32%) 1fr',
gap: '0.25rem 0.75rem',
alignItems: 'start',
marginTop: '0.35rem',
lineHeight: 1.45,
}
return (
<dl style={{ margin: '0.5rem 0 0', padding: 0, fontSize: '0.875rem', color: 'var(--text2)' }}>
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt>
<dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd>
</div>
{styleDir ? (
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Stilrichtung</dt>
<dd style={{ margin: 0 }}>{styleDir}</dd>
</div>
) : null}
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Trainingsarten</dt>
<dd style={{ margin: 0 }}>{trainingTypes.length ? trainingTypes : '—'}</dd>
</div>
<div style={rowStyle}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Zielgruppen</dt>
<dd style={{ margin: 0 }}>{targetGroups.length ? targetGroups : '—'}</dd>
</div>
<div style={{ ...rowStyle, marginTop: '0.5rem' }}>
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Kurzbeschreibung</dt>
<dd style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
{(r.description && String(r.description).trim()) || '—'}
</dd>
</div>
</dl>
)
}
export default function TrainingFrameworkProgramsListPage() {
const { user } = useAuth()
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const list = await api.listTrainingFrameworkPrograms()
setRows(Array.isArray(list) ? list : [])
} catch (e) {
setError(e.message || 'Laden fehlgeschlagen')
setRows([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load, tenantClubDepKey])
async function handleDelete(id, title) {
if (!confirm(`Rahmenprogramm „${title || id}“ wirklich löschen?`)) return
try {
await api.deleteTrainingFrameworkProgram(id)
await load()
} catch (e) {
alert(e.message || 'Löschen fehlgeschlagen')
}
}
return (
<div className="app-page">
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '1rem',
marginBottom: '1.25rem',
}}
>
<div>
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
Trainingsrahmenprogramme
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem', margin: 0 }}>
Vorlagen für Ziele und Sessions die Verknüpfung mit Gruppenterminen erfolgt in der{' '}
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>
.
</p>
<details className="planning-filter-help" style={{ marginTop: '10px', maxWidth: '36rem' }}>
<summary className="planning-filter-help__summary">Mehr zur Übernahme in die Planung</summary>
<div className="planning-filter-help__body">
Unter <strong>Planung</strong> wählst du eine Gruppe und übernimmst Slots aus einem Rahmenprogramm in
echte Termine. So bleibt die Bibliothek wiederverwendbar, ohne dass Einzelgruppen fest verdrahtet sind.
</div>
</details>
</div>
<Link
to="/planning/framework-programs/new"
className="btn btn-primary"
style={{ textDecoration: 'none', whiteSpace: 'nowrap' }}
>
Rahmenprogramm anlegen
</Link>
</div>
<p style={{ marginBottom: '1rem' }}>
<Link to="/planning" style={{ color: 'var(--accent-dark)' }}>
Zurück zur Trainingsplanung
</Link>
</p>
{error && (
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
{error}
</div>
)}
{loading ? (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<div className="spinner" />
<p>Laden</p>
</div>
) : rows.length === 0 ? (
<div className="card">
<p style={{ color: 'var(--text2)', marginBottom: '1rem' }}>
Noch kein Rahmenprogramm gespeichert. Lege ein neues an mit Titel, mindestens einem Ziel und optional
Slots samt Übungen.
</p>
<Link
to="/planning/framework-programs/new"
className="btn btn-primary btn-full"
style={{ textDecoration: 'none' }}
>
Rahmenprogramm anlegen
</Link>
</div>
) : (
<ul className="framework-programs-list">
{rows.map((r) => (
<li key={r.id} className="card">
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: '0.75rem',
}}
>
<div style={{ minWidth: 0, flex: '1 1 220px' }}>
<Link
to={`/planning/framework-programs/${r.id}`}
style={{ fontWeight: 600, fontSize: '1.05rem', color: 'var(--text1)' }}
>
{r.title || `Rahmen #${r.id}`}
</Link>
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
<span>
{(r.goals_count ?? '—') + ' Ziele · '}
{(r.slots_count ?? '—') + ' Slots'}
</span>
</div>
<FrameworkSummaryMeta r={r} />
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
<Link
to={`/planning/framework-programs/${r.id}`}
className="btn btn-secondary"
style={{ textDecoration: 'none' }}
>
Bearbeiten
</Link>
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
Löschen
</button>
</div>
</div>
</li>
))}
</ul>
)}
</div>
)
}