From a4548f558767381cc607e4fea0f0925ec01e6ddf Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 20 May 2026 16:21:16 +0200 Subject: [PATCH] Refactor Training Framework Programs List Page with Enhanced Styling and New Utility Functions - 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. --- frontend/src/app.css | 357 +++++++++++++++++- .../planning/FrameworkProgramListCard.jsx | 134 +++++++ .../TrainingFrameworkProgramsListPage.jsx | 301 +++++---------- .../src/pages/TrainingModulesListPage.jsx | 7 +- .../src/utils/frameworkProgramListHelpers.js | 29 ++ .../utils/frameworkProgramListHelpers.test.js | 8 + 6 files changed, 617 insertions(+), 219 deletions(-) create mode 100644 frontend/src/components/planning/FrameworkProgramListCard.jsx diff --git a/frontend/src/app.css b/frontend/src/app.css index 13e2801..afabd4e 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; diff --git a/frontend/src/components/planning/FrameworkProgramListCard.jsx b/frontend/src/components/planning/FrameworkProgramListCard.jsx new file mode 100644 index 0000000..2ccd4ed --- /dev/null +++ b/frontend/src/components/planning/FrameworkProgramListCard.jsx @@ -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 ( +
+ {label} + +
+ ) +} + +/** + * 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 ( +
+
+ ) +} diff --git a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx index e67a4f4..d504c20 100644 --- a/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx +++ b/frontend/src/pages/TrainingFrameworkProgramsListPage.jsx @@ -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 ( -
-
-
Session-Dauer
-
{durationLabel}
-
- {goals ? ( -
-
Entwicklungsziele
-
{goals}
-
- ) : null} -
-
Fokusbereich
-
{dashIfEmpty(focus)}
-
-
-
Stilrichtungen
-
{dashIfEmpty(styleDir)}
-
-
-
Trainingsarten
-
{trainingTypes.length ? trainingTypes : '—'}
-
-
-
Zielgruppen
-
{targetGroups.length ? targetGroups : '—'}
-
-
-
Kurzbeschreibung
-
- {(r.description && String(r.description).trim()) || '—'} -
-
-
- ) -} - export default function TrainingFrameworkProgramsListPage() { const { user } = useAuth() const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user]) @@ -145,154 +71,99 @@ export default function TrainingFrameworkProgramsListPage() { } return ( - <> -
+
+
+

Trainingsrahmenprogramme

+

+ Vorlagen für Entwicklungsziele und Sessions — die Übernahme in Gruppentermine erfolgt in der + Trainingsplanung. +

+
+ Mehr zur Übernahme in die Planung +
+ Unter Planung 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. +
+
+
+ -
-

- Trainingsrahmenprogramme -

-

- Vorlagen für Ziele und Sessions — die Verknüpfung mit Gruppenterminen erfolgt in der - Trainingsplanung (Registerkarte oben). -

-
- Mehr zur Übernahme in die Planung -
- Unter Planung 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. -
-
+ Rahmenprogramm anlegen + +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {loading ? ( +
+ + ) : rows.length === 0 ? ( +
+ +

Noch keine Rahmenprogramme

+

+ Lege ein neues Programm an — mit Titel, mindestens einem Entwicklungsziel und optional Sessions samt + Übungsablauf. +

- Rahmenprogramm anlegen + Erstes Rahmenprogramm anlegen
+ ) : ( + <> + - {error && ( -
- {error} -
- )} - - {loading ? ( -
-
-

Laden…

-
- ) : rows.length === 0 ? ( -
-

- Noch kein Rahmenprogramm gespeichert. Lege ein neues an — mit Titel, mindestens einem Ziel und optional - Slots samt Übungen. -

- - Rahmenprogramm anlegen - -
- ) : ( - <> - - - {filteredRows.length === 0 ? ( -
-

- {filterActive - ? 'Kein Rahmenprogramm passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.' - : 'Keine Einträge.'} -

-
- ) : ( -
    - {filteredRows.map((r) => ( -
  • -
    -
    - - {r.title || `Rahmen #${r.id}`} - -
    - - {frameworkSessionDurationLabel(r)} - - - {(r.goals_count ?? '—') + ' Ziele · '} - {(r.slots_count ?? '—') + ' Slots'} - -
    - -
    -
    - - Bearbeiten - - -
    -
    -
  • - ))} -
- )} - - )} - + {filteredRows.length === 0 ? ( +
+

Kein Treffer

+

+ {filterActive + ? 'Kein Rahmenprogramm passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.' + : 'Keine Einträge.'} +

+
+ ) : ( +
    + {filteredRows.map((r) => ( +
  • + +
  • + ))} +
+ )} + + )} +
) } diff --git a/frontend/src/pages/TrainingModulesListPage.jsx b/frontend/src/pages/TrainingModulesListPage.jsx index 6ab62e1..ecea0db 100644 --- a/frontend/src/pages/TrainingModulesListPage.jsx +++ b/frontend/src/pages/TrainingModulesListPage.jsx @@ -119,13 +119,14 @@ export default function TrainingModulesListPage() {

- Bearbeiten - + diff --git a/frontend/src/utils/frameworkProgramListHelpers.js b/frontend/src/utils/frameworkProgramListHelpers.js index 9bdc564..4621f04 100644 --- a/frontend/src/utils/frameworkProgramListHelpers.js +++ b/frontend/src/utils/frameworkProgramListHelpers.js @@ -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) diff --git a/frontend/src/utils/frameworkProgramListHelpers.test.js b/frontend/src/utils/frameworkProgramListHelpers.test.js index dbc8416..e528d53 100644 --- a/frontend/src/utils/frameworkProgramListHelpers.test.js +++ b/frontend/src/utils/frameworkProgramListHelpers.test.js @@ -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']) + }) })