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);
}
/* 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) */
.planning-filter-help {
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-shell {
width: 100%;
@ -2962,36 +3081,26 @@ button.capture-shell__nav-item {
}
.framework-edit__tabbar {
display: flex;
gap: 6px;
align-items: stretch;
gap: 8px;
margin-bottom: 14px;
padding: 2px 0 12px;
padding: 6px 0 12px;
border-bottom: 1px solid var(--border);
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
position: sticky;
top: 0;
z-index: 6;
background: var(--bg);
}
.framework-edit__tabbar::-webkit-scrollbar {
display: none;
}
.framework-edit__tab {
flex: 1 1 0;
.framework-edit__tabbar .planning-segment-group {
flex: 1;
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 {
display: flex;

View File

@ -5,22 +5,24 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
const [saving, setSaving] = useState(false)
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) {
setSaving(true)
try {
if (currentlyAssigned) {
// Find and delete the assignment
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) {
await api.deleteStyleDirectionTargetGroup(assignment.id)
}
} else {
// Create new assignment
await api.createStyleDirectionTargetGroup({
style_direction_id: styleDirectionId,
target_group_id: targetGroupId,
@ -37,11 +39,10 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
function isAssigned(styleDirectionId, targetGroupId) {
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 key = sd.focus_area_name || 'Ohne Fokusbereich'
if (!acc[key]) acc[key] = []
@ -50,30 +51,30 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
}, {})
return (
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
<h2 style={{ marginTop: 0 }}>Zuordnungen: Stilrichtungen Zielgruppen</h2>
{error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface2)', borderRadius: '8px', marginBottom: '16px' }}>{error}</div>}
<div className="admin-assignments-wrap">
<h2 className="admin-assignments-wrap__title">Zuordnungen: Stilrichtungen Zielgruppen</h2>
{error && <div className="admin-matrix-alert">{error}</div>}
{targetGroups.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}>
Keine Zielgruppen vorhanden. Bitte erst im Tab "Kataloge" anlegen.
<div className="empty-state" style={{ padding: '2rem 1rem' }}>
Keine Zielgruppen vorhanden. Bitte zuerst unter <strong>Kataloge</strong> anlegen.
</div>
)}
{styleDirections.length === 0 && (
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '40px' }}>
Keine Stilrichtungen vorhanden. Bitte erst im Tab "Hierarchie" anlegen.
<div className="empty-state" style={{ padding: '2rem 1rem' }}>
Keine Stilrichtungen vorhanden. Bitte zuerst unter <strong>Hierarchie</strong> anlegen.
</div>
)}
{targetGroups.length > 0 && styleDirections.length > 0 && (
<div className="assignment-matrix-container">
<table className="assignment-matrix">
<div className="admin-assignments-matrix-container">
<table className="admin-assignments-matrix">
<thead>
<tr>
<th style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 2 }}>Stilrichtung</th>
{targetGroups.map(tg => (
<th key={tg.id} style={{ textAlign: 'center', padding: '12px' }}>
<th className="admin-assignments-matrix__corner">Stilrichtung</th>
{targetGroups.map((tg) => (
<th key={tg.id} className="admin-assignments-matrix__th-narrow">
{tg.name}
</th>
))}
@ -82,17 +83,18 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
<tbody>
{Object.entries(groupedStyles).map(([focusAreaName, styles]) => (
<React.Fragment key={focusAreaName}>
<tr className="focus-area-header">
<td colSpan={targetGroups.length + 1} style={{ background: 'var(--surface2)', padding: '8px 12px', fontWeight: 600, color: 'var(--text2)' }}>
<tr>
<td
className="admin-assignments-matrix__focus-header"
colSpan={targetGroups.length + 1}
>
{focusAreaName}
</td>
</tr>
{styles.map(sd => (
{styles.map((sd) => (
<tr key={sd.id}>
<td style={{ position: 'sticky', left: 0, background: 'var(--surface)', zIndex: 1, padding: '12px', fontWeight: 500 }}>
{sd.name}
</td>
{targetGroups.map(tg => {
<td className="admin-assignments-matrix__row-label">{sd.name}</td>
{targetGroups.map((tg) => {
const assigned = isAssigned(sd.id, tg.id)
return (
<td key={tg.id} style={{ textAlign: 'center', padding: '12px' }}>
@ -101,7 +103,8 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
checked={assigned}
onChange={() => toggleAssignment(sd.id, tg.id, assigned)}
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>
)
@ -114,45 +117,6 @@ function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, e
</table>
</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>
)
}

View File

@ -663,52 +663,39 @@ export default function TrainingFrameworkProgramEditPage() {
<h1 style={{ marginBottom: '0.75rem' }}>{isNew ? 'Neues Rahmenprogramm' : 'Rahmenprogramm bearbeiten'}</h1>
<div className="card" style={{ marginBottom: '1rem', background: 'var(--surface2)', borderStyle: 'dashed' }}>
<p style={{ fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.55, margin: 0 }}>
<details className="framework-edit-intro">
<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
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>Abschnitte</strong>, Übungen mit Varianten und Dauer, <strong>ZwischenAnmerkungen</strong>.
</p>
</div>
</div>
</details>
<div
className="framework-edit__tabbar"
role="tablist"
aria-label="Bereiche"
style={
desktopLayout
? { display: 'none' }
: {
display: 'flex',
gap: 6,
marginBottom: 14,
padding: '6px 0 12px',
borderBottom: '2px solid var(--accent)',
flexWrap: 'nowrap',
overflowX: 'auto',
position: 'sticky',
top: 0,
zIndex: 6,
background: 'var(--bg)',
<div className="framework-edit__tabbar" role="tablist" aria-label="Bereiche">
<div className="planning-segment-group planning-segment-group--equal">
{[
{ id: 'meta', label: 'Stammdaten' },
{ id: 'plan', label: 'Plan (Ziele & Sessions)' },
].map((t) => (
<button
key={t.id}
type="button"
role="tab"
aria-selected={frameworkTab === t.id}
className={
'planning-segment-group__btn' +
(frameworkTab === t.id ? ' planning-segment-group__btn--active' : '')
}
}
>
{[
{ id: 'meta', label: 'Stammdaten' },
{ id: 'plan', label: 'Plan (Ziele & Sessions)' },
].map((t) => (
<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>
))}
onClick={() => setFrameworkTab(t.id)}
>
{t.label}
</button>
))}
</div>
</div>
<div

View File

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

View File

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