feat: Phase C - Admin-UI M:N Hierarchie & Matrix
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 2m1s

Frontend Admin-UI Refactoring (komplett):

1. Target Groups Tab überarbeitet (Global):
   - training_style_id Dropdown entfernt (Create + Edit)
   - Hierarchie-Anzeige entfernt (jetzt global unabhängig)
   - Nur noch Name, Beschreibung, Altersbereich

2. Neuer Tab 'Hierarchie' (Tree-View):
   - Fokusbereich → Trainingsstil → Zielgruppen
   - Expandable/Collapsible Nodes (▶/▼)
   - is_primary Kennzeichnung (★)
   - Hierarchische Darstellung mit Einrückung

3. Neuer Tab 'Zuordnungen' (M:N Matrix):
   - Checkbox-Matrix: Stile × Zielgruppen
   - Live-Toggle (Checkbox on/off)
   - Focus Area Kontext bei jedem Stil
   - is_primary Flag Anzeige (★)

UX:
- Tab-Reihenfolge: Fokusbereiche → Stile → Hierarchie → Zielgruppen → Zuordnungen
- Responsive Tabelle mit Overflow-Scroll
- Konsistente Card-basierte Layouts

Version: 0.3.4
Page: AdminCatalogsPage 2.0.0
This commit is contained in:
Lars 2026-04-23 10:55:35 +02:00
parent 1891a4ab88
commit f243b236be
2 changed files with 252 additions and 47 deletions

View File

@ -26,16 +26,24 @@ export default function AdminCatalogsPage() {
const [editingSC, setEditingSC] = useState(null)
const [newSC, setNewSC] = useState({ name: '', description: '', parent_category_id: null })
// Target Groups
// Target Groups (Global - unabhängig von Stilen)
const [targetGroups, setTargetGroups] = useState([])
const [editingTG, setEditingTG] = useState(null)
const [newTG, setNewTG] = useState({ name: '', description: '', training_style_id: null, min_age: null, max_age: null })
const [newTG, setNewTG] = useState({ name: '', description: '', min_age: null, max_age: null })
// Trainer Focus Areas
const [trainerAssignments, setTrainerAssignments] = useState([])
const [profiles, setProfiles] = useState([])
const [newAssignment, setNewAssignment] = useState({ profile_id: '', focus_area_id: '' })
// Hierarchy (Tree-View)
const [hierarchyData, setHierarchyData] = useState([])
const [expandedNodes, setExpandedNodes] = useState(new Set())
// M:N Assignment Matrix
const [assignments, setAssignments] = useState([])
const [matrixLoading, setMatrixLoading] = useState(false)
useEffect(() => {
loadData()
}, [activeTab])
@ -57,12 +65,8 @@ export default function AdminCatalogsPage() {
const data = await api.listSkillCategories()
setSkillCategories(data)
} else if (activeTab === 'target-groups') {
const [groups, styles] = await Promise.all([
api.listTargetGroups(),
api.listTrainingStyles()
])
const groups = await api.listTargetGroups()
setTargetGroups(groups)
setTrainingStyles(styles)
} else if (activeTab === 'trainer-assignments') {
const [assignments, profs, areas] = await Promise.all([
api.listTrainerFocusAreas(),
@ -72,6 +76,18 @@ export default function AdminCatalogsPage() {
setTrainerAssignments(assignments)
setProfiles(profs)
setFocusAreas(areas)
} else if (activeTab === 'hierarchy') {
const data = await api.getTrainingStylesHierarchy()
setHierarchyData(data)
} else if (activeTab === 'target-groups-matrix') {
const [styles, groups, assigns] = await Promise.all([
api.listTrainingStyles(),
api.listTargetGroups(),
api.listTrainingStyleTargetGroups()
])
setTrainingStyles(styles)
setTargetGroups(groups)
setAssignments(assigns)
}
} catch (e) {
setError(e.message)
@ -208,7 +224,7 @@ export default function AdminCatalogsPage() {
async function createTargetGroup() {
try {
await api.createTargetGroup(newTG)
setNewTG({ name: '', description: '', training_style_id: null, min_age: null, max_age: null })
setNewTG({ name: '', description: '', min_age: null, max_age: null })
loadData()
} catch (e) {
setError(e.message)
@ -265,9 +281,11 @@ export default function AdminCatalogsPage() {
{[
{ id: 'focus-areas', label: 'Fokusbereiche' },
{ id: 'training-styles', label: 'Trainingsstile' },
{ id: 'hierarchy', label: 'Hierarchie' },
{ id: 'target-groups', label: 'Zielgruppen' },
{ id: 'target-groups-matrix', label: 'Zuordnungen' },
{ id: 'training-characters', label: 'Trainingscharakter' },
{ id: 'skill-categories', label: 'Fähigkeitskategorien' },
{ id: 'target-groups', label: 'Zielgruppen' },
{ id: 'trainer-assignments', label: 'Trainer-Zuordnungen' }
].map(tab => (
<button
@ -688,20 +706,7 @@ export default function AdminCatalogsPage() {
{activeTab === 'target-groups' && (
<div>
<div className="card" style={{ marginBottom: '24px' }}>
<h3>Neue Zielgruppe</h3>
<div className="form-row">
<label className="form-label">Trainingsstil</label>
<select
className="form-input"
value={newTG.training_style_id || ''}
onChange={e => setNewTG({ ...newTG, training_style_id: e.target.value ? parseInt(e.target.value) : null })}
>
<option value="">- Stil auswählen -</option>
{trainingStyles.map(ts => (
<option key={ts.id} value={ts.id}>{ts.name}</option>
))}
</select>
</div>
<h3>Neue Zielgruppe (Global)</h3>
<div className="form-row">
<label className="form-label">Name</label>
<input
@ -748,21 +753,6 @@ export default function AdminCatalogsPage() {
<div key={tg.id} className="card">
{editingTG === tg.id ? (
<div>
<div className="form-row">
<label className="form-label">Trainingsstil</label>
<select
className="form-input"
value={tg.training_style_id || ''}
onChange={e => setTargetGroups(targetGroups.map(x =>
x.id === tg.id ? { ...x, training_style_id: e.target.value ? parseInt(e.target.value) : null } : x
))}
>
<option value="">- Stil auswählen -</option>
{trainingStyles.map(ts => (
<option key={ts.id} value={ts.id}>{ts.name}</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Name</label>
<input
@ -817,14 +807,11 @@ export default function AdminCatalogsPage() {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h3 style={{ margin: 0 }}>{tg.name}</h3>
<p style={{ margin: '4px 0', color: 'var(--text2)', fontSize: '14px' }}>
{tg.focus_area_name} {tg.training_style_name}
{(tg.min_age || tg.max_age) && (
<span style={{ marginLeft: '8px' }}>
({tg.min_age || '∞'}-{tg.max_age || '∞'} Jahre)
</span>
)}
</p>
{(tg.min_age || tg.max_age) && (
<p style={{ margin: '4px 0', color: 'var(--text2)', fontSize: '14px' }}>
Alter: {tg.min_age || '∞'}-{tg.max_age || '∞'} Jahre
</p>
)}
{tg.description && (
<p style={{ margin: '8px 0 0 0', fontSize: '14px' }}>{tg.description}</p>
)}
@ -892,6 +879,224 @@ export default function AdminCatalogsPage() {
</div>
</div>
)}
{/* Hierarchy Tree-View */}
{activeTab === 'hierarchy' && (
<div>
<div className="card" style={{ marginBottom: '16px', background: 'var(--surface2)' }}>
<h3 style={{ margin: 0, marginBottom: '8px' }}>Fokusbereich Trainingsstil Zielgruppen</h3>
<p style={{ margin: 0, fontSize: '14px', color: 'var(--text2)' }}>
Hierarchische Ansicht der Katalog-Struktur. Zuordnungen verwalten Sie im Tab "Zuordnungen".
</p>
</div>
{hierarchyData.map(fa => (
<div key={fa.id} className="card" style={{ marginBottom: '16px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer',
padding: '4px 0'
}}
onClick={() => {
const newExpanded = new Set(expandedNodes)
if (newExpanded.has(`fa-${fa.id}`)) {
newExpanded.delete(`fa-${fa.id}`)
} else {
newExpanded.add(`fa-${fa.id}`)
}
setExpandedNodes(newExpanded)
}}
>
<span style={{ fontSize: '16px' }}>
{expandedNodes.has(`fa-${fa.id}`) ? '▼' : '▶'}
</span>
<span style={{ fontSize: '24px' }}>{fa.icon || '📁'}</span>
<div>
<h3 style={{ margin: 0 }}>{fa.name}</h3>
<p style={{ margin: '4px 0 0 0', fontSize: '14px', color: 'var(--text2)' }}>
{fa.training_styles?.length || 0} Trainingsstil(e)
</p>
</div>
</div>
{expandedNodes.has(`fa-${fa.id}`) && fa.training_styles && fa.training_styles.length > 0 && (
<div style={{ marginLeft: '40px', marginTop: '12px', borderLeft: '2px solid var(--border)', paddingLeft: '16px' }}>
{fa.training_styles.map(ts => (
<div key={ts.id} style={{ marginBottom: '12px' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
cursor: 'pointer',
padding: '4px 0'
}}
onClick={(e) => {
e.stopPropagation()
const newExpanded = new Set(expandedNodes)
if (newExpanded.has(`ts-${ts.id}`)) {
newExpanded.delete(`ts-${ts.id}`)
} else {
newExpanded.add(`ts-${ts.id}`)
}
setExpandedNodes(newExpanded)
}}
>
<span style={{ fontSize: '14px' }}>
{expandedNodes.has(`ts-${ts.id}`) ? '▼' : '▶'}
</span>
<div>
<h4 style={{ margin: 0 }}>{ts.name}</h4>
<p style={{ margin: '4px 0 0 0', fontSize: '13px', color: 'var(--text2)' }}>
{ts.target_groups?.length || 0} Zielgruppe(n)
</p>
</div>
</div>
{expandedNodes.has(`ts-${ts.id}`) && ts.target_groups && ts.target_groups.length > 0 && (
<div style={{ marginLeft: '30px', marginTop: '8px' }}>
{ts.target_groups.map(tg => (
<div
key={tg.id}
style={{
padding: '8px 12px',
background: 'var(--surface)',
borderRadius: '6px',
marginBottom: '6px',
border: tg.is_primary ? '2px solid var(--accent)' : '1px solid var(--border)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontWeight: tg.is_primary ? 600 : 400 }}>
{tg.name}
{tg.is_primary && <span style={{ marginLeft: '6px', fontSize: '12px', color: 'var(--accent)' }}></span>}
</span>
{(tg.min_age || tg.max_age) && (
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
{tg.min_age || '∞'}-{tg.max_age || '∞'} Jahre
</span>
)}
</div>
{tg.description && (
<p style={{ margin: '4px 0 0 0', fontSize: '12px', color: 'var(--text2)' }}>
{tg.description}
</p>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{/* M:N Assignment Matrix */}
{activeTab === 'target-groups-matrix' && (
<div>
<div className="card" style={{ marginBottom: '16px', background: 'var(--surface2)' }}>
<h3 style={{ margin: 0, marginBottom: '8px' }}>Zielgruppen-Zuordnungsmatrix</h3>
<p style={{ margin: 0, fontSize: '14px', color: 'var(--text2)' }}>
Ordnen Sie Zielgruppen den Trainingsstilen zu. Eine Zielgruppe kann mehreren Stilen zugeordnet sein.
</p>
</div>
{trainingStyles.length > 0 && targetGroups.length > 0 ? (
<div className="card" style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border)' }}>
<th style={{ textAlign: 'left', padding: '12px 8px', fontWeight: 600 }}>
Trainingsstil
</th>
{targetGroups.map(tg => (
<th
key={tg.id}
style={{
textAlign: 'center',
padding: '12px 8px',
fontWeight: 600,
minWidth: '100px'
}}
>
{tg.name}
</th>
))}
</tr>
</thead>
<tbody>
{trainingStyles.map(ts => (
<tr key={ts.id} style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '12px 8px' }}>
<div>
<strong>{ts.name}</strong>
{ts.focus_area_name && (
<div style={{ fontSize: '12px', color: 'var(--text2)' }}>
{ts.focus_area_name}
</div>
)}
</div>
</td>
{targetGroups.map(tg => {
const assignment = assignments.find(
a => a.training_style_id === ts.id && a.target_group_id === tg.id
)
const isAssigned = !!assignment
return (
<td key={tg.id} style={{ textAlign: 'center', padding: '12px 8px' }}>
<input
type="checkbox"
checked={isAssigned}
onChange={async () => {
try {
if (isAssigned) {
await api.deleteTrainingStyleTargetGroup(assignment.id)
} else {
await api.createTrainingStyleTargetGroup({
training_style_id: ts.id,
target_group_id: tg.id,
is_primary: false
})
}
loadData()
} catch (e) {
setError(e.message)
}
}}
style={{
width: '18px',
height: '18px',
cursor: 'pointer'
}}
/>
{isAssigned && assignment.is_primary && (
<span style={{ marginLeft: '4px', color: 'var(--accent)' }}></span>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="card">
<p style={{ margin: 0, color: 'var(--text2)' }}>
Keine Daten verfügbar. Bitte erstellen Sie zuerst Trainingsstile und Zielgruppen.
</p>
</div>
)}
</div>
)}
</>
)}
</div>

View File

@ -11,5 +11,5 @@ export const PAGE_VERSIONS = {
ClubsPage: "1.0.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.0.0",
AdminCatalogsPage: "1.1.0", // Updated: Target Groups tab
AdminCatalogsPage: "2.0.0", // Updated: M:N Refactoring - Hierarchy Tree + Matrix
}