Refactor Training Framework Programs List Page with Enhanced Styling and New Utility Functions
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Has been cancelled
Some checks failed
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Has been cancelled
- Updated the TrainingFrameworkProgramsListPage to utilize new CSS classes for improved layout and styling. - Removed deprecated components and functions, streamlining the codebase for better maintainability. - Introduced utility functions for splitting aggregated strings, enhancing data handling for framework program attributes. - Enhanced the user interface with loading and empty state indicators, improving overall user experience.
This commit is contained in:
parent
9d122d4808
commit
a4548f5587
|
|
@ -5380,7 +5380,362 @@ html.modal-scroll-locked .app-main {
|
|||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Liste Rahmenprogramme: Abstand nur über gap (kein .card+.card zwischen li) */
|
||||
/* —— Rahmenprogramm-Bibliothek (Liste) —— */
|
||||
.fw-prog-page__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.fw-prog-page__intro {
|
||||
flex: 1 1 16rem;
|
||||
min-width: 0;
|
||||
max-width: 40rem;
|
||||
}
|
||||
.fw-prog-page__title {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.fw-prog-page__lead {
|
||||
margin: 0;
|
||||
color: var(--text2);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
max-width: 36rem;
|
||||
}
|
||||
.fw-prog-page__help {
|
||||
margin-top: 10px;
|
||||
max-width: 36rem;
|
||||
}
|
||||
.fw-prog-page__cta {
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fw-prog-page__error {
|
||||
border-left: 4px solid var(--danger);
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text1);
|
||||
}
|
||||
.fw-prog-page__loading {
|
||||
text-align: center;
|
||||
padding: 2.5rem 1.5rem;
|
||||
}
|
||||
.fw-prog-page__loading p {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--text2);
|
||||
}
|
||||
.fw-prog-page__empty {
|
||||
text-align: center;
|
||||
padding: 2rem 1.25rem 1.75rem;
|
||||
}
|
||||
.fw-prog-page__empty--filter {
|
||||
margin-top: 1rem;
|
||||
padding: 1.5rem 1.25rem;
|
||||
}
|
||||
.fw-prog-page__empty-icon {
|
||||
font-size: 2.25rem;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.75rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.fw-prog-page__empty-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text1);
|
||||
}
|
||||
.fw-prog-page__empty-text {
|
||||
margin: 0 0 1.25rem;
|
||||
color: var(--text2);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
max-width: 28rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
.fw-prog-page__empty .btn-full {
|
||||
max-width: 20rem;
|
||||
margin: 0 auto;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.fw-prog-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.fw-prog-list > li {
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fw-prog-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
margin-bottom: 0;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.fw-prog-card:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.fw-prog-card__accent {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-dark) 100%);
|
||||
border-radius: 12px 0 0 12px;
|
||||
}
|
||||
.fw-prog-card__inner {
|
||||
padding: 1.1rem 1.15rem 1.15rem 1.35rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.fw-prog-card__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem 1.25rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.fw-prog-card__title-block {
|
||||
flex: 1 1 12rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.fw-prog-card__title {
|
||||
margin: 0;
|
||||
font-size: 1.12rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.fw-prog-card__title-link {
|
||||
color: var(--accent-dark);
|
||||
text-decoration: none;
|
||||
word-break: break-word;
|
||||
}
|
||||
.fw-prog-card__title-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.fw-prog-card__desc {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text2);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fw-prog-card__stats {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fw-prog-card__stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 4.5rem;
|
||||
padding: 0.45rem 0.65rem;
|
||||
border-radius: 10px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
.fw-prog-card__stat--duration {
|
||||
background: var(--accent-light);
|
||||
border-color: color-mix(in srgb, var(--accent) 25%, var(--border));
|
||||
}
|
||||
.fw-prog-card__stat--duration .fw-prog-card__stat-value {
|
||||
color: var(--accent-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
.fw-prog-card__stat--muted .fw-prog-card__stat-value {
|
||||
color: var(--text3);
|
||||
font-weight: 500;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.fw-prog-card__stat-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text3);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.fw-prog-card__stat-value {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text1);
|
||||
line-height: 1.25;
|
||||
max-width: 6.5rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.fw-prog-card__section {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.fw-prog-card__section-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text3);
|
||||
}
|
||||
.fw-prog-card__goal-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 8px;
|
||||
}
|
||||
.fw-prog-card__goal {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
color: var(--text1);
|
||||
padding: 5px 11px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-light);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border));
|
||||
line-height: 1.35;
|
||||
max-width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
.fw-prog-card__catalog {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
|
||||
gap: 0.65rem 1rem;
|
||||
}
|
||||
.fw-prog-card__catalog-group {
|
||||
min-width: 0;
|
||||
}
|
||||
.fw-prog-card__catalog-label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.fw-prog-card__chip-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 6px;
|
||||
}
|
||||
.fw-prog-card__chip {
|
||||
font-size: 0.78rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface2);
|
||||
color: var(--text2);
|
||||
border: 1px solid var(--border);
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
.fw-prog-card__catalog-group--focus .fw-prog-card__chip {
|
||||
border-color: color-mix(in srgb, var(--accent) 18%, var(--border));
|
||||
background: color-mix(in srgb, var(--accent-light) 60%, var(--surface2));
|
||||
}
|
||||
.fw-prog-card__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.fw-prog-card__btn-primary {
|
||||
text-decoration: none;
|
||||
flex: 1 1 auto;
|
||||
min-width: 7rem;
|
||||
justify-content: center;
|
||||
}
|
||||
.fw-prog-card__btn-danger {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.fw-prog-card__btn-danger:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.fw-prog-card__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.fw-prog-card__btn-primary {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.fw-prog-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.fw-prog-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.fw-prog-card__inner {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.fw-prog-card__actions {
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.fw-prog-page__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.fw-prog-page__cta {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.fw-prog-card__head {
|
||||
flex-direction: column;
|
||||
}
|
||||
.fw-prog-card__stats {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
.fw-prog-card__stat {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.fw-prog-card__catalog {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.fw-prog-card__actions .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Legacy-Klasse (falls noch referenziert) */
|
||||
.framework-programs-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
|
|
|||
134
frontend/src/components/planning/FrameworkProgramListCard.jsx
Normal file
134
frontend/src/components/planning/FrameworkProgramListCard.jsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import React from 'react'
|
||||
import NavStateLink from '../NavStateLink'
|
||||
import {
|
||||
frameworkSessionDurationLabel,
|
||||
splitFrameworkCommaAgg,
|
||||
splitFrameworkGoalsAgg,
|
||||
frameworkProgramHasCatalogMeta,
|
||||
} from '../../utils/frameworkProgramListHelpers'
|
||||
|
||||
function CatalogGroup({ label, items, variant }) {
|
||||
if (!items.length) return null
|
||||
return (
|
||||
<div className={`fw-prog-card__catalog-group fw-prog-card__catalog-group--${variant}`}>
|
||||
<span className="fw-prog-card__catalog-label">{label}</span>
|
||||
<ul className="fw-prog-card__chip-list" aria-label={label}>
|
||||
{items.map((name) => (
|
||||
<li key={name} className="fw-prog-card__chip">
|
||||
{name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelkarte für die Rahmenprogramm-Bibliothek.
|
||||
*/
|
||||
export default function FrameworkProgramListCard({ row, returnContext, onDelete }) {
|
||||
const title = (row.title || '').trim() || `Rahmen #${row.id}`
|
||||
const description = (row.description || '').trim()
|
||||
const durationLabel = frameworkSessionDurationLabel(row)
|
||||
const hasDuration = durationLabel !== 'Dauer nicht angegeben'
|
||||
const goals = splitFrameworkGoalsAgg(row.goal_titles_agg)
|
||||
const focusAreas = splitFrameworkCommaAgg(row.focus_area_names_agg)
|
||||
const styleDirs = splitFrameworkCommaAgg(
|
||||
row.style_direction_names_agg || row.style_direction_name
|
||||
)
|
||||
const trainingTypes = splitFrameworkCommaAgg(row.training_type_names_agg)
|
||||
const targetGroups = splitFrameworkCommaAgg(row.target_group_names_agg)
|
||||
const goalsCount = Number(row.goals_count)
|
||||
const slotsCount = Number(row.slots_count)
|
||||
const showCatalog = frameworkProgramHasCatalogMeta(row)
|
||||
|
||||
return (
|
||||
<article className="fw-prog-card card">
|
||||
<div className="fw-prog-card__accent" aria-hidden="true" />
|
||||
<div className="fw-prog-card__inner">
|
||||
<header className="fw-prog-card__head">
|
||||
<div className="fw-prog-card__title-block">
|
||||
<h2 className="fw-prog-card__title">
|
||||
<NavStateLink
|
||||
to={`/planning/framework-programs/${row.id}`}
|
||||
returnContext={returnContext}
|
||||
className="fw-prog-card__title-link"
|
||||
>
|
||||
{title}
|
||||
</NavStateLink>
|
||||
</h2>
|
||||
{description ? (
|
||||
<p className="fw-prog-card__desc">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ul className="fw-prog-card__stats" aria-label="Kennzahlen">
|
||||
<li
|
||||
className={
|
||||
'fw-prog-card__stat' +
|
||||
(hasDuration ? ' fw-prog-card__stat--duration' : ' fw-prog-card__stat--muted')
|
||||
}
|
||||
>
|
||||
<span className="fw-prog-card__stat-label">Session</span>
|
||||
<span className="fw-prog-card__stat-value">{durationLabel}</span>
|
||||
</li>
|
||||
<li className="fw-prog-card__stat">
|
||||
<span className="fw-prog-card__stat-label">Ziele</span>
|
||||
<span className="fw-prog-card__stat-value">
|
||||
{Number.isFinite(goalsCount) ? goalsCount : '—'}
|
||||
</span>
|
||||
</li>
|
||||
<li className="fw-prog-card__stat">
|
||||
<span className="fw-prog-card__stat-label">Sessions</span>
|
||||
<span className="fw-prog-card__stat-value">
|
||||
{Number.isFinite(slotsCount) ? slotsCount : '—'}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
{goals.length > 0 ? (
|
||||
<section className="fw-prog-card__section fw-prog-card__section--goals">
|
||||
<h3 className="fw-prog-card__section-title">Entwicklungsziele</h3>
|
||||
<ul className="fw-prog-card__goal-list" aria-label="Entwicklungsziele">
|
||||
{goals.map((g) => (
|
||||
<li key={g} className="fw-prog-card__goal">
|
||||
{g}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{showCatalog ? (
|
||||
<section className="fw-prog-card__section fw-prog-card__section--catalog">
|
||||
<h3 className="fw-prog-card__section-title">Einordnung</h3>
|
||||
<div className="fw-prog-card__catalog">
|
||||
<CatalogGroup label="Fokus" items={focusAreas} variant="focus" />
|
||||
<CatalogGroup label="Stil" items={styleDirs} variant="style" />
|
||||
<CatalogGroup label="Trainingsart" items={trainingTypes} variant="type" />
|
||||
<CatalogGroup label="Zielgruppe" items={targetGroups} variant="target" />
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="fw-prog-card__actions">
|
||||
<NavStateLink
|
||||
to={`/planning/framework-programs/${row.id}`}
|
||||
returnContext={returnContext}
|
||||
className="btn btn-primary fw-prog-card__btn-primary"
|
||||
>
|
||||
Öffnen
|
||||
</NavStateLink>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary fw-prog-card__btn-danger"
|
||||
onClick={() => onDelete(row.id, row.title)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,90 +2,16 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|||
import api from '../utils/api'
|
||||
import NavStateLink from '../components/NavStateLink'
|
||||
import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock'
|
||||
import FrameworkProgramListCard from '../components/planning/FrameworkProgramListCard'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
|
||||
import {
|
||||
EMPTY_FRAMEWORK_IMPORT_FILTERS,
|
||||
filterFrameworkPrograms,
|
||||
frameworkSessionDurationLabel,
|
||||
hasActiveFrameworkImportFilters,
|
||||
} from '../utils/frameworkProgramListHelpers'
|
||||
|
||||
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_names_agg === 'string'
|
||||
? r.style_direction_names_agg.trim()
|
||||
: typeof r.style_direction_name === 'string'
|
||||
? r.style_direction_name.trim()
|
||||
: ''
|
||||
const focus =
|
||||
typeof r.focus_area_names_agg === 'string'
|
||||
? r.focus_area_names_agg.trim()
|
||||
: 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,
|
||||
}
|
||||
|
||||
const durationLabel = frameworkSessionDurationLabel(r)
|
||||
const goals =
|
||||
typeof r.goal_titles_agg === 'string' ? r.goal_titles_agg.trim() : ''
|
||||
|
||||
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)' }}>Session-Dauer</dt>
|
||||
<dd style={{ margin: 0 }}>{durationLabel}</dd>
|
||||
</div>
|
||||
{goals ? (
|
||||
<div style={rowStyle}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Entwicklungsziele</dt>
|
||||
<dd style={{ margin: 0 }}>{goals}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={rowStyle}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Fokusbereich</dt>
|
||||
<dd style={{ margin: 0 }}>{dashIfEmpty(focus)}</dd>
|
||||
</div>
|
||||
<div style={rowStyle}>
|
||||
<dt style={{ margin: 0, fontWeight: 600, color: 'var(--text3)' }}>Stilrichtungen</dt>
|
||||
<dd style={{ margin: 0 }}>{dashIfEmpty(styleDir)}</dd>
|
||||
</div>
|
||||
<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])
|
||||
|
|
@ -145,154 +71,99 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
marginBottom: '1.25rem',
|
||||
}}
|
||||
<div className="fw-prog-page">
|
||||
<header className="fw-prog-page__header">
|
||||
<div className="fw-prog-page__intro">
|
||||
<h1 className="page-title fw-prog-page__title">Trainingsrahmenprogramme</h1>
|
||||
<p className="fw-prog-page__lead">
|
||||
Vorlagen für Entwicklungsziele und Sessions — die Übernahme in Gruppentermine erfolgt in der
|
||||
Trainingsplanung.
|
||||
</p>
|
||||
<details className="planning-filter-help fw-prog-page__help">
|
||||
<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>
|
||||
<NavStateLink
|
||||
to="/planning/framework-programs/new"
|
||||
returnContext={frameworkListReturn}
|
||||
className="btn btn-primary fw-prog-page__cta"
|
||||
>
|
||||
<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
|
||||
Trainingsplanung (Registerkarte oben).
|
||||
</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>
|
||||
Rahmenprogramm anlegen
|
||||
</NavStateLink>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className="card fw-prog-page__error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="fw-prog-page__loading card">
|
||||
<div className="spinner" aria-hidden="true" />
|
||||
<p>Rahmenprogramme werden geladen…</p>
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="card fw-prog-page__empty">
|
||||
<div className="fw-prog-page__empty-icon" aria-hidden="true">
|
||||
📋
|
||||
</div>
|
||||
<h2 className="fw-prog-page__empty-title">Noch keine Rahmenprogramme</h2>
|
||||
<p className="fw-prog-page__empty-text">
|
||||
Lege ein neues Programm an — mit Titel, mindestens einem Entwicklungsziel und optional Sessions samt
|
||||
Übungsablauf.
|
||||
</p>
|
||||
<NavStateLink
|
||||
to="/planning/framework-programs/new"
|
||||
returnContext={frameworkListReturn}
|
||||
className="btn btn-primary"
|
||||
style={{ textDecoration: 'none', whiteSpace: 'nowrap' }}
|
||||
className="btn btn-primary btn-full"
|
||||
>
|
||||
Rahmenprogramm anlegen
|
||||
Erstes Rahmenprogramm anlegen
|
||||
</NavStateLink>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FrameworkProgramsFilterBlock
|
||||
programs={rows}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
panelOpen={filterPanelOpen}
|
||||
onPanelOpenChange={setFilterPanelOpen}
|
||||
catalogFocusAreas={catalogFocusAreas}
|
||||
catalogTrainingTypes={catalogTrainingTypes}
|
||||
catalogTargetGroups={catalogTargetGroups}
|
||||
durationRadioName="fw-list-duration-mode"
|
||||
className="fw-prog-filter-block--list"
|
||||
/>
|
||||
|
||||
{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>
|
||||
<NavStateLink
|
||||
to="/planning/framework-programs/new"
|
||||
returnContext={frameworkListReturn}
|
||||
className="btn btn-primary btn-full"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
Rahmenprogramm anlegen
|
||||
</NavStateLink>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<FrameworkProgramsFilterBlock
|
||||
programs={rows}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
panelOpen={filterPanelOpen}
|
||||
onPanelOpenChange={setFilterPanelOpen}
|
||||
catalogFocusAreas={catalogFocusAreas}
|
||||
catalogTrainingTypes={catalogTrainingTypes}
|
||||
catalogTargetGroups={catalogTargetGroups}
|
||||
durationRadioName="fw-list-duration-mode"
|
||||
className="fw-prog-filter-block--list"
|
||||
/>
|
||||
|
||||
{filteredRows.length === 0 ? (
|
||||
<div className="card" style={{ marginTop: '1rem' }}>
|
||||
<p style={{ color: 'var(--text2)', margin: 0 }}>
|
||||
{filterActive
|
||||
? 'Kein Rahmenprogramm passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.'
|
||||
: 'Keine Einträge.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="framework-programs-list">
|
||||
{filteredRows.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' }}>
|
||||
<NavStateLink
|
||||
to={`/planning/framework-programs/${r.id}`}
|
||||
returnContext={frameworkListReturn}
|
||||
style={{ fontWeight: 600, fontSize: '1.05rem', color: 'var(--text1)' }}
|
||||
>
|
||||
{r.title || `Rahmen #${r.id}`}
|
||||
</NavStateLink>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--text2)', marginTop: '0.35rem' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginRight: '8px',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '6px',
|
||||
background: 'var(--surface2)',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text1)',
|
||||
}}
|
||||
>
|
||||
{frameworkSessionDurationLabel(r)}
|
||||
</span>
|
||||
<span>
|
||||
{(r.goals_count ?? '—') + ' Ziele · '}
|
||||
{(r.slots_count ?? '—') + ' Slots'}
|
||||
</span>
|
||||
</div>
|
||||
<FrameworkSummaryMeta r={r} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
<NavStateLink
|
||||
to={`/planning/framework-programs/${r.id}`}
|
||||
returnContext={frameworkListReturn}
|
||||
className="btn btn-secondary"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
Bearbeiten
|
||||
</NavStateLink>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{filteredRows.length === 0 ? (
|
||||
<div className="card fw-prog-page__empty fw-prog-page__empty--filter">
|
||||
<h2 className="fw-prog-page__empty-title">Kein Treffer</h2>
|
||||
<p className="fw-prog-page__empty-text">
|
||||
{filterActive
|
||||
? 'Kein Rahmenprogramm passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.'
|
||||
: 'Keine Einträge.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="fw-prog-list" aria-label="Rahmenprogramme">
|
||||
{filteredRows.map((r) => (
|
||||
<li key={r.id}>
|
||||
<FrameworkProgramListCard
|
||||
row={r}
|
||||
returnContext={frameworkListReturn}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,13 +119,14 @@ export default function TrainingModulesListPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<Link
|
||||
<NavStateLink
|
||||
to={`/planning/training-modules/${r.id}`}
|
||||
returnContext={modulesListReturn}
|
||||
className="btn btn-secondary"
|
||||
style={{ textDecoration: 'none' }}
|
||||
to={`/planning/training-modules/${r.id}`}
|
||||
>
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</NavStateLink>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
|
||||
Löschen
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,35 @@ export function frameworkSessionDurationLabel(row) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Komma-getrennte Aggregat-Strings aus der Listen-API in Einträge zerlegen. */
|
||||
export function splitFrameworkCommaAgg(value) {
|
||||
const s = (value ?? '').toString().trim()
|
||||
if (!s) return []
|
||||
return s
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
/** Entwicklungsziele aus goal_titles_agg (Trenner „|“). */
|
||||
export function splitFrameworkGoalsAgg(value) {
|
||||
const s = (value ?? '').toString().trim()
|
||||
if (!s) return []
|
||||
return s
|
||||
.split('|')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function frameworkProgramHasCatalogMeta(row) {
|
||||
return (
|
||||
splitFrameworkCommaAgg(row?.focus_area_names_agg).length > 0 ||
|
||||
splitFrameworkCommaAgg(row?.style_direction_names_agg).length > 0 ||
|
||||
splitFrameworkCommaAgg(row?.training_type_names_agg).length > 0 ||
|
||||
splitFrameworkCommaAgg(row?.target_group_names_agg).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
function parseIdList(raw) {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map((x) => String(x)).filter(Boolean)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
collectDistinctSessionDurationsMinutes,
|
||||
filterFrameworkPrograms,
|
||||
hasActiveFrameworkImportFilters,
|
||||
splitFrameworkCommaAgg,
|
||||
splitFrameworkGoalsAgg,
|
||||
} from './frameworkProgramListHelpers.js'
|
||||
|
||||
describe('frameworkProgramListHelpers', () => {
|
||||
|
|
@ -35,4 +37,10 @@ describe('frameworkProgramListHelpers', () => {
|
|||
expect(hasActiveFrameworkImportFilters({ query: 'x' })).toBe(true)
|
||||
expect(hasActiveFrameworkImportFilters({ durationMode: 'preset', durationPresetMin: 60 })).toBe(true)
|
||||
})
|
||||
|
||||
it('splitFrameworkCommaAgg and splitFrameworkGoalsAgg', () => {
|
||||
expect(splitFrameworkCommaAgg('Technik, Kondition')).toEqual(['Technik', 'Kondition'])
|
||||
expect(splitFrameworkCommaAgg('')).toEqual([])
|
||||
expect(splitFrameworkGoalsAgg('Gürtel | Koordination')).toEqual(['Gürtel', 'Koordination'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user