UX - Filter #12

Merged
Lars merged 22 commits from develop into main 2026-05-06 21:25:56 +02:00
4 changed files with 303 additions and 88 deletions
Showing only changes of commit 657f73d2c5 - Show all commits

View File

@ -1411,6 +1411,20 @@ button.capture-shell__nav-item {
.nav-item span { .nav-item span {
font-size: 9.5px; 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) */ /* Trainingsplanung: kompakte Segmente (Gruppe / Verein) */
@ -1461,6 +1475,180 @@ button.capture-shell__nav-item {
font-size: 0.92rem; 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) */ /* Ausklappbare Kontext-Hilfe (Filterzeile Planung) */
.planning-filter-help { .planning-filter-help {
flex: 1 1 100%; flex: 1 1 100%;

View File

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

View File

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

View File

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