feat: enhance UI and functionality in Training Framework pages
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 7s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 53s

- Added new CSS styles for segment buttons and admin assignment matrix, improving layout and responsiveness.
- Refactored AssignmentsTab component to utilize new styles and improve accessibility with aria-labels.
- Introduced collapsible details for framework edit introduction, enhancing user guidance.
- Updated TrainingPlanningPage to streamline button styling and improve visual consistency across components.
This commit is contained in:
Lars 2026-05-06 10:46:40 +02:00
parent 14884e6e55
commit 18a58cb5a5
5 changed files with 212 additions and 161 deletions

View File

@ -1213,6 +1213,22 @@ button.capture-shell__nav-item {
border-left: 1.5px solid var(--border2); border-left: 1.5px solid var(--border2);
} }
/* Gleich breite Segment-Buttons (z. B. mobile Rahmenprogramm-Tabs) */
.planning-segment-group--equal {
flex: 1;
min-width: 0;
}
.planning-segment-group--equal .planning-segment-group__btn {
flex: 1;
min-width: 0;
}
/* Etwas größere Segmente (Planung: Liste / Kalender) */
.planning-segment-group--comfort .planning-segment-group__btn {
padding: 10px 18px;
font-size: 0.92rem;
}
/* Ausklappbare Kontext-Hilfe (Filterzeile Planung) */ /* Ausklappbare Kontext-Hilfe (Filterzeile Planung) */
.planning-filter-help { .planning-filter-help {
flex: 1 1 100%; flex: 1 1 100%;
@ -1246,6 +1262,109 @@ button.capture-shell__nav-item {
} }
} }
/* Rahmenprogramm-Editor: Kurz-Einstieg ausklappbar */
.framework-edit-intro {
margin-bottom: 1rem;
}
.framework-edit-intro__summary {
cursor: pointer;
font-size: 0.88rem;
font-weight: 600;
color: var(--accent-dark);
list-style: none;
user-select: none;
padding: 10px 12px;
border-radius: 10px;
border: 1px dashed var(--border2);
background: var(--surface2);
}
.framework-edit-intro__summary::-webkit-details-marker {
display: none;
}
.framework-edit-intro__body {
margin-top: 10px;
padding: 12px 14px;
font-size: 0.88rem;
line-height: 1.55;
color: var(--text2);
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
}
@media (prefers-color-scheme: dark) {
.framework-edit-intro__summary {
color: var(--accent);
}
}
/* Admin: Zuordnungsmatrix (Stilrichtungen ↔ Zielgruppen) */
.admin-assignments-wrap {
background: var(--surface);
border-radius: 12px;
padding: 20px;
}
.admin-assignments-wrap__title {
margin-top: 0;
font-size: 1.15rem;
font-weight: 700;
}
.admin-assignments-matrix-container {
overflow-x: auto;
margin-top: 20px;
-webkit-overflow-scrolling: touch;
}
.admin-assignments-matrix {
width: 100%;
border-collapse: collapse;
min-width: 600px;
}
.admin-assignments-matrix th,
.admin-assignments-matrix td {
border: 1px solid var(--border);
padding: 12px;
}
.admin-assignments-matrix th {
background: var(--surface2);
font-weight: 600;
color: var(--text1);
}
.admin-assignments-matrix__corner {
position: sticky;
left: 0;
background: var(--surface);
z-index: 2;
}
.admin-assignments-matrix__row-label {
position: sticky;
left: 0;
background: var(--surface);
z-index: 1;
padding: 12px;
font-weight: 500;
}
.admin-assignments-matrix tbody tr:hover {
background: var(--surface2);
}
.admin-assignments-matrix__focus-header td {
background: var(--surface2);
padding: 8px 12px;
font-weight: 600;
color: var(--text2);
}
.admin-assignments-matrix__th-narrow {
text-align: center;
padding: 12px;
}
@media (max-width: 768px) {
.admin-assignments-matrix {
font-size: 14px;
}
.admin-assignments-matrix th,
.admin-assignments-matrix td {
padding: 8px;
}
}
/* Admin: Split-Layout wie .analysis-split (nur Gruppen in der Nav) */ /* Admin: Split-Layout wie .analysis-split (nur Gruppen in der Nav) */
.admin-shell { .admin-shell {
width: 100%; width: 100%;
@ -2962,36 +3081,26 @@ button.capture-shell__nav-item {
} }
.framework-edit__tabbar { .framework-edit__tabbar {
display: flex; display: flex;
gap: 6px; align-items: stretch;
gap: 8px;
margin-bottom: 14px; margin-bottom: 14px;
padding: 2px 0 12px; padding: 6px 0 12px;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-wrap: nowrap; flex-wrap: nowrap;
overflow-x: auto; overflow-x: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scrollbar-width: none; scrollbar-width: none;
position: sticky;
top: 0;
z-index: 6;
background: var(--bg);
} }
.framework-edit__tabbar::-webkit-scrollbar { .framework-edit__tabbar::-webkit-scrollbar {
display: none; display: none;
} }
.framework-edit__tab { .framework-edit__tabbar .planning-segment-group {
flex: 1 1 0; flex: 1;
min-width: 0; min-width: 0;
padding: 10px 8px;
border: 1px solid var(--border2);
border-radius: 10px;
background: var(--surface2);
color: var(--text2);
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
font-family: var(--font);
}
.framework-edit__tab--active {
background: var(--accent-light);
color: var(--accent-dark);
border-color: var(--accent);
} }
.framework-edit__plan-stack { .framework-edit__plan-stack {
display: flex; display: flex;

View File

@ -5,22 +5,24 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
if (loading) { if (loading) {
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div> return (
<div className="empty-state" style={{ padding: '2.5rem' }}>
<div className="spinner" />
</div>
)
} }
async function toggleAssignment(styleDirectionId, targetGroupId, currentlyAssigned) { async function toggleAssignment(styleDirectionId, targetGroupId, currentlyAssigned) {
setSaving(true) setSaving(true)
try { try {
if (currentlyAssigned) { if (currentlyAssigned) {
// Find and delete the assignment
const assignment = assignments.find( const assignment = assignments.find(
a => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId (a) => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
) )
if (assignment) { if (assignment) {
await api.deleteStyleDirectionTargetGroup(assignment.id) await api.deleteStyleDirectionTargetGroup(assignment.id)
} }
} else { } else {
// Create new assignment
await api.createStyleDirectionTargetGroup({ await api.createStyleDirectionTargetGroup({
style_direction_id: styleDirectionId, style_direction_id: styleDirectionId,
target_group_id: targetGroupId, target_group_id: targetGroupId,
@ -37,11 +39,10 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
function isAssigned(styleDirectionId, targetGroupId) { function isAssigned(styleDirectionId, targetGroupId) {
return assignments.some( return assignments.some(
a => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId (a) => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
) )
} }
// Group style directions by focus area
const groupedStyles = styleDirections.reduce((acc, sd) => { const groupedStyles = styleDirections.reduce((acc, sd) => {
const key = sd.focus_area_name || 'Ohne Fokusbereich' const key = sd.focus_area_name || 'Ohne Fokusbereich'
if (!acc[key]) acc[key] = [] if (!acc[key]) acc[key] = []
@ -50,30 +51,30 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
}, {}) }, {})
return ( return (
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}> <div className="admin-assignments-wrap">
<h2 style={{ marginTop: 0 }}>Zuordnungen: Stilrichtungen Zielgruppen</h2> <h2 className="admin-assignments-wrap__title">Zuordnungen: Stilrichtungen Zielgruppen</h2>
{error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '16px' }}>{error}</div>} {error && <div className="admin-matrix-alert">{error}</div>}
{targetGroups.length === 0 && ( {targetGroups.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}> <div className="empty-state" style={{ padding: '2rem 1rem' }}>
Keine Zielgruppen vorhanden. Bitte erst im Tab "Kataloge" anlegen. Keine Zielgruppen vorhanden. Bitte zuerst unter <strong>Kataloge</strong> anlegen.
</div> </div>
)} )}
{styleDirections.length === 0 && ( {styleDirections.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}> <div className="empty-state" style={{ padding: '2rem 1rem' }}>
Keine Stilrichtungen vorhanden. Bitte erst im Tab "Hierarchie" anlegen. Keine Stilrichtungen vorhanden. Bitte zuerst unter <strong>Hierarchie</strong> anlegen.
</div> </div>
)} )}
{targetGroups.length > 0 && styleDirections.length > 0 && ( {targetGroups.length > 0 && styleDirections.length > 0 && (
<div className="assignment-matrix-container"> <div className="admin-assignments-matrix-container">
<table className="assignment-matrix"> <table className="admin-assignments-matrix">
<thead> <thead>
<tr> <tr>
<th style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 2 }}>Stilrichtung</th> <th className="admin-assignments-matrix__corner">Stilrichtung</th>
{targetGroups.map(tg => ( {targetGroups.map((tg) => (
<th key={tg.id} style={{ textAlign: 'center', padding: '12px' }}> <th key={tg.id} className="admin-assignments-matrix__th-narrow">
{tg.name} {tg.name}
</th> </th>
))} ))}
@ -82,17 +83,18 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
<tbody> <tbody>
{Object.entries(groupedStyles).map(([focusAreaName, styles]) => ( {Object.entries(groupedStyles).map(([focusAreaName, styles]) => (
<React.Fragment key={focusAreaName}> <React.Fragment key={focusAreaName}>
<tr className="focus-area-header"> <tr>
<td colSpan={targetGroups.length + 1} style={{ background: 'var(--surface2)', padding: '8px 12px', fontWeight: 600, color: 'var(--text2)' }}> <td
className="admin-assignments-matrix__focus-header"
colSpan={targetGroups.length + 1}
>
{focusAreaName} {focusAreaName}
</td> </td>
</tr> </tr>
{styles.map(sd => ( {styles.map((sd) => (
<tr key={sd.id}> <tr key={sd.id}>
<td style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 1, padding: '12px', fontWeight: 500 }}> <td className="admin-assignments-matrix__row-label">{sd.name}</td>
{sd.name} {targetGroups.map((tg) => {
</td>
{targetGroups.map(tg => {
const assigned = isAssigned(sd.id, tg.id) const assigned = isAssigned(sd.id, tg.id)
return ( return (
<td key={tg.id} style={{ textAlign: 'center', padding: '12px' }}> <td key={tg.id} style={{ textAlign: 'center', padding: '12px' }}>
@ -101,7 +103,8 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
checked={assigned} checked={assigned}
onChange={() => toggleAssignment(sd.id, tg.id, assigned)} onChange={() => toggleAssignment(sd.id, tg.id, assigned)}
disabled={saving} disabled={saving}
style={{ width: '20px', height: '20px', cursor: 'pointer' }} aria-label={`${sd.name}${tg.name}`}
style={{ width: '20px', height: '20px', cursor: 'pointer', accentColor: 'var(--accent)' }}
/> />
</td> </td>
) )
@ -114,45 +117,6 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
</table> </table>
</div> </div>
)} )}
<style>{`
.assignment-matrix-container {
overflow-x: auto;
margin-top: 20px;
}
.assignment-matrix {
width: 100%;
border-collapse: collapse;
min-width: 600px;
}
.assignment-matrix th,
.assignment-matrix td {
border: 1px solid var(--border);
padding: 12px;
}
.assignment-matrix th {
background: var(--surface2);
font-weight: 600;
color: var(--text1);
}
.assignment-matrix tbody tr:hover {
background: var(--surface2);
}
@media (max-width: 768px) {
.assignment-matrix {
font-size: 14px;
}
.assignment-matrix th,
.assignment-matrix td {
padding: 8px;
}
}
`}</style>
</div> </div>
) )
} }

View File

@ -663,52 +663,39 @@ export default function TrainingFrameworkProgramEditPage() {
<h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1> <h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1>
<div className="card" style={{ marginBottom: '1rem', background: 'var(--surface2)', borderStyle: 'dashed' }}> <details className="framework-edit-intro">
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.55, margin: 0 }}> <summary className="framework-edit-intro__summary">
Kurz erklärt: Was ist ein Rahmenprogramm?
</summary>
<div className="framework-edit-intro__body">
<strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit <strong style={{ color: 'var(--text1)' }}>Rahmenprogramm (Bibliothek):</strong> Wiederverwendbare Vorlage mit
Zielen und SessionSlots. <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '} Zielen und SessionSlots. Die <strong>Zuordnung zu Gruppe oder Kalendertermin</strong> erfolgt aus der{' '}
<strong>GruppenPlanung</strong> (Übernahme). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '} <strong>GruppenPlanung</strong> (Übernahme). Pro Slot planst du den Ablauf wie bei einer Trainingsseinheit:{' '}
<strong>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>ZwischenAnmerkungen</strong>. <strong>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>ZwischenAnmerkungen</strong>.
</p> </div>
</div> </details>
<div <div className="framework-edit__tabbar" role="tablist" aria-label="Bereiche">
className="framework-edit__tabbar" <div className="planning-segment-group planning-segment-group--equal">
role="tablist" {[
aria-label="Bereiche" { id: 'meta', label: 'Stammdaten' },
style={ { id: 'plan', label: 'Plan (Ziele & Sessions)' },
desktopLayout ].map((t) => (
? { display: 'none' } <button
: { key={t.id}
display: 'flex', type="button"
gap: 6, role="tab"
marginBottom: 14, aria-selected={frameworkTab === t.id}
padding: '6px 0 12px', className={
borderBottom: '2px solid var(--accent)', 'planning-segment-group__btn' +
flexWrap: 'nowrap', (frameworkTab === t.id ? ' planning-segment-group__btn--active' : '')
overflowX: 'auto',
position: 'sticky',
top: 0,
zIndex: 6,
background: 'var(--bg)',
} }
} onClick={() => setFrameworkTab(t.id)}
> >
{[ {t.label}
{ id: 'meta', label: 'Stammdaten' }, </button>
{ id: 'plan', label: 'Plan (Ziele & Sessions)' }, ))}
].map((t) => ( </div>
<button
key={t.id}
type="button"
role="tab"
aria-selected={frameworkTab === t.id}
className={'framework-edit__tab' + (frameworkTab === t.id ? ' framework-edit__tab--active' : '')}
onClick={() => setFrameworkTab(t.id)}
>
{t.label}
</button>
))}
</div> </div>
<div <div

View File

@ -100,12 +100,23 @@ export default function TrainingFrameworkProgramsListPage() {
}} }}
> >
<div> <div>
<h1 style={{ marginBottom: '0.35rem' }}>Trainingsrahmenprogramme</h1> <h1 className="page-title" style={{ marginBottom: '0.35rem' }}>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem' }}> Trainingsrahmenprogramme
Wiederverwendbare Vorlagen für Ziele und Sessions. Die Verknüpfung mit{' '} </h1>
<strong>konkreten Gruppeneinheiten</strong> erfolgt aus der <strong>Planung der Gruppe</strong> (Übernahme <p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '36rem', margin: 0 }}>
mit Bezug zum Rahmen). Vorlagen für Ziele und Sessions die Verknüpfung mit Gruppenterminen erfolgt in der{' '}
<Link to="/planning" style={{ fontWeight: 600, color: 'var(--accent-dark)' }}>
Trainingsplanung
</Link>
.
</p> </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>
</div> </div>
<Link <Link
to="/planning/framework-programs/new" to="/planning/framework-programs/new"

View File

@ -865,31 +865,18 @@ function TrainingPlanningPage() {
Ansicht Ansicht
</span> </span>
<div <div
className="planning-segment-group planning-segment-group--comfort"
role="group" role="group"
aria-label="Darstellung Liste oder Kalender" aria-label="Darstellung Liste oder Kalender"
style={{
display: 'inline-flex',
borderRadius: '10px',
border: '1.5px solid var(--border2)',
overflow: 'hidden',
background: 'var(--surface2)',
boxSizing: 'border-box',
}}
> >
<button <button
type="button" type="button"
aria-pressed={planView === 'list'} aria-pressed={planView === 'list'}
onClick={() => setPlanView('list')} onClick={() => setPlanView('list')}
style={{ className={
border: 'none', 'planning-segment-group__btn' +
padding: '10px 20px', (planView === 'list' ? ' planning-segment-group__btn--active' : '')
fontWeight: 600, }
fontSize: '0.92rem',
cursor: 'pointer',
background: planView === 'list' ? 'var(--accent-dark)' : 'transparent',
color: planView === 'list' ? '#fff' : 'var(--text1)',
whiteSpace: 'nowrap',
}}
> >
Liste Liste
</button> </button>
@ -904,17 +891,10 @@ function TrainingPlanningPage() {
return prev || new Date().toISOString().slice(0, 7) return prev || new Date().toISOString().slice(0, 7)
}) })
}} }}
style={{ className={
border: 'none', 'planning-segment-group__btn' +
borderLeft: '1.5px solid var(--border2)', (planView === 'calendar' ? ' planning-segment-group__btn--active' : '')
padding: '10px 20px', }
fontWeight: 600,
fontSize: '0.92rem',
cursor: 'pointer',
background: planView === 'calendar' ? 'var(--accent-dark)' : 'transparent',
color: planView === 'calendar' ? '#fff' : 'var(--text1)',
whiteSpace: 'nowrap',
}}
> >
Kalender Kalender
</button> </button>
@ -927,8 +907,8 @@ function TrainingPlanningPage() {
</div> </div>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem' }}> <p style={{ color: 'var(--text2)', fontSize: '0.95rem', marginBottom: '1.25rem' }}>
Wähle eine Trainingsgruppe, lege dann Termine mit Inhalt (Abschnitte und Übungen) an ein Plan entsteht aus einer oder mehreren{' '} Wähle eine Trainingsgruppe und lege <strong>Trainingseinheiten</strong> für den Zeitraum an (Inhalt: Abschnitte
<strong>Trainingseinheiten</strong> im gewählten Zeitraum. und Übungen).
</p> </p>
<div className="card" style={{ marginBottom: '1.25rem', padding: '12px 14px' }}> <div className="card" style={{ marginBottom: '1.25rem', padding: '12px 14px' }}>