feat: Phase C - Admin-UI M:N Hierarchie & Matrix
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:
parent
1891a4ab88
commit
f243b236be
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user