Verbesserung Darstellung Rahmenprogramme #42
|
|
@ -5380,7 +5380,362 @@ html.modal-scroll-locked .app-main {
|
||||||
margin-top: 0.75rem;
|
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 {
|
.framework-programs-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
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 api from '../utils/api'
|
||||||
import NavStateLink from '../components/NavStateLink'
|
import NavStateLink from '../components/NavStateLink'
|
||||||
import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock'
|
import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock'
|
||||||
|
import FrameworkProgramListCard from '../components/planning/FrameworkProgramListCard'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
|
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
|
||||||
import {
|
import {
|
||||||
EMPTY_FRAMEWORK_IMPORT_FILTERS,
|
EMPTY_FRAMEWORK_IMPORT_FILTERS,
|
||||||
filterFrameworkPrograms,
|
filterFrameworkPrograms,
|
||||||
frameworkSessionDurationLabel,
|
|
||||||
hasActiveFrameworkImportFilters,
|
hasActiveFrameworkImportFilters,
|
||||||
} from '../utils/frameworkProgramListHelpers'
|
} 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() {
|
export default function TrainingFrameworkProgramsListPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
||||||
|
|
@ -145,154 +71,99 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="fw-prog-page">
|
||||||
<div
|
<header className="fw-prog-page__header">
|
||||||
style={{
|
<div className="fw-prog-page__intro">
|
||||||
display: 'flex',
|
<h1 className="page-title fw-prog-page__title">Trainingsrahmenprogramme</h1>
|
||||||
flexWrap: 'wrap',
|
<p className="fw-prog-page__lead">
|
||||||
alignItems: 'flex-start',
|
Vorlagen für Entwicklungsziele und Sessions — die Übernahme in Gruppentermine erfolgt in der
|
||||||
justifyContent: 'space-between',
|
Trainingsplanung.
|
||||||
gap: '1rem',
|
</p>
|
||||||
marginBottom: '1.25rem',
|
<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>
|
Rahmenprogramm anlegen
|
||||||
<h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
|
</NavStateLink>
|
||||||
Trainingsrahmenprogramme
|
</header>
|
||||||
</h1>
|
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem', margin: 0 }}>
|
{error ? (
|
||||||
Vorlagen für Ziele und Sessions — die Verknüpfung mit Gruppenterminen erfolgt in der
|
<div className="card fw-prog-page__error" role="alert">
|
||||||
Trainingsplanung (Registerkarte oben).
|
{error}
|
||||||
</p>
|
</div>
|
||||||
<details className="planning-filter-help" style={{ marginTop: '10px', maxWidth: '36rem' }}>
|
) : null}
|
||||||
<summary className="planning-filter-help__summary">Mehr zur Übernahme in die Planung</summary>
|
|
||||||
<div className="planning-filter-help__body">
|
{loading ? (
|
||||||
Unter <strong>Planung</strong> wählst du eine Gruppe und übernimmst Slots aus einem Rahmenprogramm in
|
<div className="fw-prog-page__loading card">
|
||||||
echte Termine. So bleibt die Bibliothek wiederverwendbar, ohne dass Einzelgruppen fest verdrahtet sind.
|
<div className="spinner" aria-hidden="true" />
|
||||||
</div>
|
<p>Rahmenprogramme werden geladen…</p>
|
||||||
</details>
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<div className="card fw-prog-page__empty">
|
||||||
|
<div className="fw-prog-page__empty-icon" aria-hidden="true">
|
||||||
|
📋
|
||||||
</div>
|
</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
|
<NavStateLink
|
||||||
to="/planning/framework-programs/new"
|
to="/planning/framework-programs/new"
|
||||||
returnContext={frameworkListReturn}
|
returnContext={frameworkListReturn}
|
||||||
className="btn btn-primary"
|
className="btn btn-primary btn-full"
|
||||||
style={{ textDecoration: 'none', whiteSpace: 'nowrap' }}
|
|
||||||
>
|
>
|
||||||
Rahmenprogramm anlegen
|
Erstes Rahmenprogramm anlegen
|
||||||
</NavStateLink>
|
</NavStateLink>
|
||||||
</div>
|
</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 && (
|
{filteredRows.length === 0 ? (
|
||||||
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
|
<div className="card fw-prog-page__empty fw-prog-page__empty--filter">
|
||||||
{error}
|
<h2 className="fw-prog-page__empty-title">Kein Treffer</h2>
|
||||||
</div>
|
<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.'
|
||||||
{loading ? (
|
: 'Keine Einträge.'}
|
||||||
<div style={{ textAlign: 'center', padding: '2rem' }}>
|
</p>
|
||||||
<div className="spinner" />
|
</div>
|
||||||
<p>Laden…</p>
|
) : (
|
||||||
</div>
|
<ul className="fw-prog-list" aria-label="Rahmenprogramme">
|
||||||
) : rows.length === 0 ? (
|
{filteredRows.map((r) => (
|
||||||
<div className="card">
|
<li key={r.id}>
|
||||||
<p style={{ color: 'var(--text2)', marginBottom: '1rem' }}>
|
<FrameworkProgramListCard
|
||||||
Noch kein Rahmenprogramm gespeichert. Lege ein neues an — mit Titel, mindestens einem Ziel und optional
|
row={r}
|
||||||
Slots samt Übungen.
|
returnContext={frameworkListReturn}
|
||||||
</p>
|
onDelete={handleDelete}
|
||||||
<NavStateLink
|
/>
|
||||||
to="/planning/framework-programs/new"
|
</li>
|
||||||
returnContext={frameworkListReturn}
|
))}
|
||||||
className="btn btn-primary btn-full"
|
</ul>
|
||||||
style={{ textDecoration: 'none' }}
|
)}
|
||||||
>
|
</>
|
||||||
Rahmenprogramm anlegen
|
)}
|
||||||
</NavStateLink>
|
</div>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,13 +119,14 @@ export default function TrainingModulesListPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
<Link
|
<NavStateLink
|
||||||
|
to={`/planning/training-modules/${r.id}`}
|
||||||
|
returnContext={modulesListReturn}
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ textDecoration: 'none' }}
|
style={{ textDecoration: 'none' }}
|
||||||
to={`/planning/training-modules/${r.id}`}
|
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</Link>
|
</NavStateLink>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
|
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
|
||||||
Löschen
|
Löschen
|
||||||
</button>
|
</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) {
|
function parseIdList(raw) {
|
||||||
if (Array.isArray(raw)) {
|
if (Array.isArray(raw)) {
|
||||||
return raw.map((x) => String(x)).filter(Boolean)
|
return raw.map((x) => String(x)).filter(Boolean)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import {
|
||||||
collectDistinctSessionDurationsMinutes,
|
collectDistinctSessionDurationsMinutes,
|
||||||
filterFrameworkPrograms,
|
filterFrameworkPrograms,
|
||||||
hasActiveFrameworkImportFilters,
|
hasActiveFrameworkImportFilters,
|
||||||
|
splitFrameworkCommaAgg,
|
||||||
|
splitFrameworkGoalsAgg,
|
||||||
} from './frameworkProgramListHelpers.js'
|
} from './frameworkProgramListHelpers.js'
|
||||||
|
|
||||||
describe('frameworkProgramListHelpers', () => {
|
describe('frameworkProgramListHelpers', () => {
|
||||||
|
|
@ -35,4 +37,10 @@ describe('frameworkProgramListHelpers', () => {
|
||||||
expect(hasActiveFrameworkImportFilters({ query: 'x' })).toBe(true)
|
expect(hasActiveFrameworkImportFilters({ query: 'x' })).toBe(true)
|
||||||
expect(hasActiveFrameworkImportFilters({ durationMode: 'preset', durationPresetMin: 60 })).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