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}
+
+ {items.map((name) => (
+
+ {name}
+
+ ))}
+
+
+ )
+}
+
+/**
+ * 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 (
+
+
+
+
+
+
+
+ {title}
+
+
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+
+ Session
+ {durationLabel}
+
+
+ Ziele
+
+ {Number.isFinite(goalsCount) ? goalsCount : '—'}
+
+
+
+ Sessions
+
+ {Number.isFinite(slotsCount) ? slotsCount : '—'}
+
+
+
+
+
+ {goals.length > 0 ? (
+
+ Entwicklungsziele
+
+ {goals.map((g) => (
+
+ {g}
+
+ ))}
+
+
+ ) : null}
+
+ {showCatalog ? (
+
+ Einordnung
+
+
+
+
+
+
+
+ ) : null}
+
+
+
+ Öffnen
+
+ onDelete(row.id, row.title)}
+ >
+ Löschen
+
+
+
+
+ )
+}
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 (
- <>
-
-
Bearbeiten
-
+
handleDelete(r.id, r.title)}>
Löschen
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'])
+ })
})