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

- 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:
Lars 2026-05-20 16:21:16 +02:00
parent 9d122d4808
commit a4548f5587
6 changed files with 617 additions and 219 deletions

View File

@ -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;

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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)

View File

@ -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'])
})
})