feat: enhance admin catalog UI and functionality
Some checks failed
Deploy Development / deploy (push) Successful in 36s
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 27s

- Added new CSS styles for admin catalog sections, improving layout and responsiveness.
- Implemented icon support for catalog section titles, enhancing visual clarity.
- Refactored loading and error states for better user experience in the CatalogsTab and HierarchyTab components.
- Updated AdminCatalogsPage to utilize new styles and improve tab navigation.
- Enhanced accessibility with appropriate ARIA roles and attributes for better usability.
This commit is contained in:
Lars 2026-05-06 11:12:59 +02:00
parent 8b86021293
commit 657f73d2c5
4 changed files with 303 additions and 88 deletions

View File

@ -1411,6 +1411,20 @@ button.capture-shell__nav-item {
.nav-item span {
font-size: 9.5px;
}
.admin-page-subtabs {
margin-left: calc(-1 * max(12px, env(safe-area-inset-left, 0px)));
margin-right: calc(-1 * max(12px, env(safe-area-inset-right, 0px)));
padding-left: max(12px, env(safe-area-inset-left, 0px));
padding-right: max(12px, env(safe-area-inset-right, 0px));
margin-bottom: 14px;
border-bottom: none;
padding-bottom: 6px;
}
.admin-catalog-section {
padding: 14px;
}
}
/* Trainingsplanung: kompakte Segmente (Gruppe / Verein) */
@ -1461,6 +1475,180 @@ button.capture-shell__nav-item {
font-size: 0.92rem;
}
/* Admin-Kataloge: Seite „Stammdaten“ — viele Unter-Tabs, Chip-Scroll */
.admin-page-subtabs {
display: flex;
flex-wrap: nowrap;
gap: 6px;
overflow-x: auto;
overflow-y: hidden;
margin-bottom: 18px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
scroll-snap-type: x proximity;
}
.admin-page-subtabs::-webkit-scrollbar {
display: none;
}
.admin-page-subtabs__btn {
flex: 0 0 auto;
scroll-snap-align: start;
margin: 0;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
padding: 8px 13px;
border-radius: 999px;
border: 1px solid var(--border2);
background: var(--surface2);
color: var(--text2);
transition: background 0.12s, color 0.12s, border-color 0.12s;
-webkit-tap-highlight-color: transparent;
}
.admin-page-subtabs__btn:hover {
border-color: var(--accent);
color: var(--text1);
}
.admin-page-subtabs__btn--active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.admin-page-subtabs__btn--active:hover {
color: #fff;
background: color-mix(in srgb, var(--accent) 92%, #000);
}
@media (min-width: 1024px) {
.admin-page-subtabs__btn {
font-size: 13px;
padding: 9px 15px;
}
}
/* Admin Hierarchy & Catalog Section (Komponenten) */
.admin-hierarchy-layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-hierarchy-pane {
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 16px;
background: var(--surface);
}
.admin-hierarchy-pane__title {
margin-top: 0;
margin-bottom: 12px;
font-size: 1.15rem;
font-weight: 700;
}
.admin-hierarchy-back {
margin-bottom: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
}
.admin-catalog-stack {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 1024px) {
.admin-catalog-stack {
gap: 1.25rem;
}
}
.admin-catalog-section {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px;
}
@media (min-width: 1024px) {
.admin-catalog-section {
padding: 20px;
}
}
.admin-catalog-section__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 16px;
}
.admin-catalog-section__title {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 8px;
}
.admin-catalog-section__icon {
flex-shrink: 0;
color: var(--accent-dark);
}
@media (prefers-color-scheme: dark) {
.admin-catalog-section__icon {
color: var(--accent);
}
}
.admin-catalog-inline-form {
margin-bottom: 16px;
padding: 14px;
background: var(--surface2);
border-radius: 10px;
border: 1px solid var(--border);
}
.admin-catalog-inline-form h4 {
margin-top: 0;
margin-bottom: 12px;
font-size: 0.95rem;
font-weight: 700;
}
.admin-catalog-list {
display: grid;
gap: 10px;
}
.admin-catalog-item {
padding: 12px;
background: var(--surface2);
border-radius: 10px;
border: 1px solid var(--border);
}
.admin-catalog-item__name-row {
margin-bottom: 8px;
}
.admin-catalog-meta {
margin-left: 10px;
color: var(--text3);
font-size: 0.875rem;
}
.admin-catalog-desc {
color: var(--text2);
font-size: 0.875rem;
margin: 8px 0;
}
.admin-catalog-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.admin-catalog-empty {
text-align: center;
color: var(--text3);
padding: 1.25rem;
font-size: 0.9rem;
}
/* Ausklappbare Kontext-Hilfe (Filterzeile Planung) */
.planning-filter-help {
flex: 1 1 100%;

View File

@ -1,18 +1,23 @@
import React, { useState } from 'react'
import { Target, Tags, Dumbbell } from 'lucide-react'
import { api } from '../../utils/api'
function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loading, error, onUpdate }) {
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>
)
}
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '24px' }}>
{error && <div style={{ color: 'var(--danger)', padding: '16px', background: 'var(--surface)', borderRadius: '8px' }}>{error}</div>}
<div className="admin-catalog-stack">
{error && <div className="admin-matrix-alert">{error}</div>}
<CatalogSection
title="Zielgruppen"
icon="🎯"
Icon={Target}
items={targetGroups}
onUpdate={onUpdate}
createFn={api.createTargetGroup}
@ -28,7 +33,7 @@ function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loadin
<CatalogSection
title="Fähigkeitskategorien"
icon="⚡"
Icon={Tags}
items={skillCategories}
onUpdate={onUpdate}
createFn={api.createSkillCategory}
@ -42,7 +47,7 @@ function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loadin
<CatalogSection
title="Trainingscharakter"
icon="💪"
Icon={Dumbbell}
items={trainingCharacters}
onUpdate={onUpdate}
createFn={api.createTrainingCharacter}
@ -57,27 +62,27 @@ function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loadin
)
}
function CatalogSection({ title, icon, items, onUpdate, createFn, updateFn, deleteFn, fields }) {
function CatalogSection({ title, Icon, items, onUpdate, createFn, updateFn, deleteFn, fields }) {
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState(null)
const [form, setForm] = useState({})
function startCreate() {
const emptyForm = {}
fields.forEach(f => { emptyForm[f.key] = '' })
fields.forEach((f) => { emptyForm[f.key] = '' })
setForm(emptyForm)
setCreating(true)
}
function startEdit(item) {
const editForm = {}
fields.forEach(f => { editForm[f.key] = item[f.key] || '' })
fields.forEach((f) => { editForm[f.key] = item[f.key] || '' })
setEditing(item.id)
setForm(editForm)
}
async function handleCreate() {
const required = fields.filter(f => f.required)
const required = fields.filter((f) => f.required)
for (const field of required) {
if (!form[field.key]) {
alert(`${field.label} ist erforderlich`)
@ -116,75 +121,116 @@ function CatalogSection({ title, icon, items, onUpdate, createFn, updateFn, dele
}
return (
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h3 style={{ margin: 0 }}>{icon} {title}</h3>
<button className="btn btn-primary" onClick={startCreate}>+ Neu</button>
<div className="admin-catalog-section">
<div className="admin-catalog-section__head">
<h3 className="admin-catalog-section__title">
{Icon ? (
<Icon className="admin-catalog-section__icon" size={20} strokeWidth={2} aria-hidden />
) : null}
{title}
</h3>
<button type="button" className="btn btn-primary btn-small" onClick={startCreate}>
+ Neu
</button>
</div>
{creating && (
<div style={{ marginBottom: '20px', padding: '16px', background: 'var(--surface2)', borderRadius: '8px' }}>
<h4 style={{ marginTop: 0 }}>Neu erstellen</h4>
{fields.map(field => (
<div className="admin-catalog-inline-form">
<h4>Neu erstellen</h4>
{fields.map((field) => (
<div key={field.key} className="form-row">
<label className="form-label">{field.label} {field.required && '*'}</label>
<label className="form-label">
{field.label} {field.required && '*'}
</label>
{field.type === 'textarea' ? (
<textarea className="form-input" value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} rows={3} />
<textarea
className="form-input"
value={form[field.key] || ''}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
rows={3}
/>
) : (
<input className="form-input" type={field.type} value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} />
<input
className="form-input"
type={field.type}
value={form[field.key] || ''}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
/>
)}
</div>
))}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button className="btn btn-primary" onClick={handleCreate}>Erstellen</button>
<button className="btn" onClick={() => setCreating(false)}>Abbrechen</button>
<div className="admin-catalog-actions">
<button type="button" className="btn btn-primary" onClick={handleCreate}>
Erstellen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setCreating(false)}>
Abbrechen
</button>
</div>
</div>
)}
<div style={{ display: 'grid', gap: '12px' }}>
{items.map(item => (
<div key={item.id} style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px' }}>
<div className="admin-catalog-list">
{items.map((item) => (
<div key={item.id} className="admin-catalog-item">
{editing === item.id ? (
<div>
{fields.map(field => (
{fields.map((field) => (
<div key={field.key} className="form-row">
<label className="form-label">{field.label}</label>
{field.type === 'textarea' ? (
<textarea className="form-input" value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} rows={3} />
<textarea
className="form-input"
value={form[field.key] || ''}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
rows={3}
/>
) : (
<input className="form-input" type={field.type} value={form[field.key] || ''} onChange={e => setForm({ ...form, [field.key]: e.target.value })} />
<input
className="form-input"
type={field.type}
value={form[field.key] || ''}
onChange={(e) => setForm({ ...form, [field.key]: e.target.value })}
/>
)}
</div>
))}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button className="btn btn-primary" onClick={() => handleUpdate(item.id)}>Speichern</button>
<button className="btn" onClick={() => setEditing(null)}>Abbrechen</button>
<div className="admin-catalog-actions">
<button type="button" className="btn btn-primary" onClick={() => handleUpdate(item.id)}>
Speichern
</button>
<button type="button" className="btn btn-secondary" onClick={() => setEditing(null)}>
Abbrechen
</button>
</div>
</div>
) : (
<div>
<div style={{ marginBottom: '8px' }}>
<div className="admin-catalog-item__name-row">
<strong>{item.name}</strong>
{item.min_age !== null && item.max_age !== null && (
<span style={{ marginLeft: '12px', color: 'var(--text3)', fontSize: '14px' }}>
{item.min_age != null && item.max_age != null && (
<span className="admin-catalog-meta">
Alter: {item.min_age}-{item.max_age}
</span>
)}
</div>
{item.description && <p style={{ color: 'var(--text2)', fontSize: '14px', margin: '8px 0' }}>{item.description}</p>}
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<button className="btn" onClick={() => startEdit(item)}>Bearbeiten</button>
<button className="btn" onClick={() => handleDelete(item.id, item.name)}>Löschen</button>
{item.description ? (
<p className="admin-catalog-desc">{item.description}</p>
) : null}
<div className="admin-catalog-actions">
<button type="button" className="btn btn-secondary btn-small" onClick={() => startEdit(item)}>
Bearbeiten
</button>
<button type="button" className="btn btn-danger btn-small" onClick={() => handleDelete(item.id, item.name)}>
Löschen
</button>
</div>
</div>
)}
</div>
))}
{items.length === 0 && !creating && (
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '20px' }}>
Noch keine Einträge vorhanden
</div>
<div className="admin-catalog-empty">Noch keine Einträge vorhanden</div>
)}
</div>
</div>

View File

@ -1,29 +1,24 @@
import React from 'react'
import { ArrowLeft } from 'lucide-react'
import FocusAreaNode from './FocusAreaNode'
import DetailPanel from './DetailPanel'
function HierarchyTab({ hierarchy, expandedNodes, selectedItem, loading, error, onToggleNode, onSelectItem, onUpdate }) {
if (loading && hierarchy.length === 0) {
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>
)
}
return (
<div className="admin-hierarchy-container">
{/* Tree View */}
<div
className="admin-tree-view"
style={{
display: selectedItem ? 'none' : 'block',
border: '1px solid var(--border)',
borderRadius: '12px',
padding: '16px',
background: 'var(--surface)'
}}
>
<h2 style={{ marginTop: 0 }}>Katalog-Hierarchie</h2>
{error && <div style={{ color: 'var(--danger)', marginBottom: '16px' }}>{error}</div>}
<div className="admin-hierarchy-container admin-hierarchy-layout">
<div className="admin-hierarchy-pane admin-hierarchy-pane--tree" hidden={!!selectedItem}>
<h2 className="admin-hierarchy-pane__title">Katalog-Hierarchie</h2>
{error && <div className="admin-matrix-alert">{error}</div>}
{hierarchy.map(fa => (
{hierarchy.map((fa) => (
<FocusAreaNode
key={fa.id}
focusArea={fa}
@ -36,22 +31,15 @@ function HierarchyTab({ hierarchy, expandedNodes, selectedItem, loading, error,
))}
</div>
{/* Detail Panel */}
{selectedItem && (
<div
style={{
border: '1px solid var(--border)',
borderRadius: '12px',
padding: '20px',
background: 'var(--surface)'
}}
>
<div className="admin-hierarchy-pane admin-hierarchy-pane--detail">
<button
className="btn admin-back-button"
type="button"
className="btn btn-secondary btn-small admin-hierarchy-back"
onClick={() => onSelectItem(null)}
style={{ marginBottom: '16px' }}
>
Zurück zur Übersicht
<ArrowLeft size={16} strokeWidth={2} aria-hidden />
Zurück zur Übersicht
</button>
<DetailPanel item={selectedItem} onUpdate={onUpdate} focusAreas={hierarchy} />
</div>

View File

@ -316,10 +316,9 @@ export default function AdminCatalogsPage() {
<div className="app-page">
<AdminPageNav />
<h1 style={{ marginBottom: '24px' }}>Stammdaten-Kataloge</h1>
<h1 className="page-title">Stammdaten-Kataloge</h1>
{/* Tabs */}
<div style={{ display: 'flex', gap: '8px', borderBottom: '2px solid var(--border)', marginBottom: '24px', overflowX: 'auto' }}>
<div className="admin-page-subtabs" role="tablist" aria-label="Katalogbereiche">
{[
{ id: 'focus-areas', label: 'Fokusbereiche' },
{ id: 'training-styles', label: 'Stilrichtungen' },
@ -330,30 +329,24 @@ export default function AdminCatalogsPage() {
{ id: 'training-characters', label: 'Trainingscharakter' },
{ id: 'skill-categories', label: 'Fähigkeitskategorien' },
{ id: 'trainer-assignments', label: 'Trainer-Zuordnungen' }
].map(tab => (
].map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
onClick={() => setActiveTab(tab.id)}
className="btn"
style={{
borderBottom: activeTab === tab.id ? '3px solid var(--accent)' : 'none',
borderRadius: 0,
fontWeight: activeTab === tab.id ? 600 : 400,
color: activeTab === tab.id ? 'var(--accent)' : 'var(--text2)',
padding: '12px 16px',
whiteSpace: 'nowrap'
}}
className={
'admin-page-subtabs__btn' +
(activeTab === tab.id ? ' admin-page-subtabs__btn--active' : '')
}
>
{tab.label}
</button>
))}
</div>
{error && (
<div style={{ padding: '12px', background: 'var(--danger)', color: 'white', borderRadius: '8px', marginBottom: '16px' }}>
{error}
</div>
)}
{error && <div className="admin-matrix-alert">{error}</div>}
{loading ? (
<div className="spinner" />