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 [editingSC, setEditingSC] = useState(null)
|
||||||
const [newSC, setNewSC] = useState({ name: '', description: '', parent_category_id: 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 [targetGroups, setTargetGroups] = useState([])
|
||||||
const [editingTG, setEditingTG] = useState(null)
|
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
|
// Trainer Focus Areas
|
||||||
const [trainerAssignments, setTrainerAssignments] = useState([])
|
const [trainerAssignments, setTrainerAssignments] = useState([])
|
||||||
const [profiles, setProfiles] = useState([])
|
const [profiles, setProfiles] = useState([])
|
||||||
const [newAssignment, setNewAssignment] = useState({ profile_id: '', focus_area_id: '' })
|
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(() => {
|
useEffect(() => {
|
||||||
loadData()
|
loadData()
|
||||||
}, [activeTab])
|
}, [activeTab])
|
||||||
|
|
@ -57,12 +65,8 @@ export default function AdminCatalogsPage() {
|
||||||
const data = await api.listSkillCategories()
|
const data = await api.listSkillCategories()
|
||||||
setSkillCategories(data)
|
setSkillCategories(data)
|
||||||
} else if (activeTab === 'target-groups') {
|
} else if (activeTab === 'target-groups') {
|
||||||
const [groups, styles] = await Promise.all([
|
const groups = await api.listTargetGroups()
|
||||||
api.listTargetGroups(),
|
|
||||||
api.listTrainingStyles()
|
|
||||||
])
|
|
||||||
setTargetGroups(groups)
|
setTargetGroups(groups)
|
||||||
setTrainingStyles(styles)
|
|
||||||
} else if (activeTab === 'trainer-assignments') {
|
} else if (activeTab === 'trainer-assignments') {
|
||||||
const [assignments, profs, areas] = await Promise.all([
|
const [assignments, profs, areas] = await Promise.all([
|
||||||
api.listTrainerFocusAreas(),
|
api.listTrainerFocusAreas(),
|
||||||
|
|
@ -72,6 +76,18 @@ export default function AdminCatalogsPage() {
|
||||||
setTrainerAssignments(assignments)
|
setTrainerAssignments(assignments)
|
||||||
setProfiles(profs)
|
setProfiles(profs)
|
||||||
setFocusAreas(areas)
|
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) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
|
|
@ -208,7 +224,7 @@ export default function AdminCatalogsPage() {
|
||||||
async function createTargetGroup() {
|
async function createTargetGroup() {
|
||||||
try {
|
try {
|
||||||
await api.createTargetGroup(newTG)
|
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()
|
loadData()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
setError(e.message)
|
||||||
|
|
@ -265,9 +281,11 @@ export default function AdminCatalogsPage() {
|
||||||
{[
|
{[
|
||||||
{ id: 'focus-areas', label: 'Fokusbereiche' },
|
{ id: 'focus-areas', label: 'Fokusbereiche' },
|
||||||
{ id: 'training-styles', label: 'Trainingsstile' },
|
{ 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: 'training-characters', label: 'Trainingscharakter' },
|
||||||
{ id: 'skill-categories', label: 'Fähigkeitskategorien' },
|
{ id: 'skill-categories', label: 'Fähigkeitskategorien' },
|
||||||
{ id: 'target-groups', label: 'Zielgruppen' },
|
|
||||||
{ id: 'trainer-assignments', label: 'Trainer-Zuordnungen' }
|
{ id: 'trainer-assignments', label: 'Trainer-Zuordnungen' }
|
||||||
].map(tab => (
|
].map(tab => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -688,20 +706,7 @@ export default function AdminCatalogsPage() {
|
||||||
{activeTab === 'target-groups' && (
|
{activeTab === 'target-groups' && (
|
||||||
<div>
|
<div>
|
||||||
<div className="card" style={{ marginBottom: '24px' }}>
|
<div className="card" style={{ marginBottom: '24px' }}>
|
||||||
<h3>Neue Zielgruppe</h3>
|
<h3>Neue Zielgruppe (Global)</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>
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label className="form-label">Name</label>
|
<label className="form-label">Name</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -748,21 +753,6 @@ export default function AdminCatalogsPage() {
|
||||||
<div key={tg.id} className="card">
|
<div key={tg.id} className="card">
|
||||||
{editingTG === tg.id ? (
|
{editingTG === tg.id ? (
|
||||||
<div>
|
<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">
|
<div className="form-row">
|
||||||
<label className="form-label">Name</label>
|
<label className="form-label">Name</label>
|
||||||
<input
|
<input
|
||||||
|
|
@ -817,14 +807,11 @@ export default function AdminCatalogsPage() {
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<div>
|
<div>
|
||||||
<h3 style={{ margin: 0 }}>{tg.name}</h3>
|
<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) && (
|
{(tg.min_age || tg.max_age) && (
|
||||||
<span style={{ marginLeft: '8px' }}>
|
<p style={{ margin: '4px 0', color: 'var(--text2)', fontSize: '14px' }}>
|
||||||
({tg.min_age || '∞'}-{tg.max_age || '∞'} Jahre)
|
Alter: {tg.min_age || '∞'}-{tg.max_age || '∞'} Jahre
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
{tg.description && (
|
{tg.description && (
|
||||||
<p style={{ margin: '8px 0 0 0', fontSize: '14px' }}>{tg.description}</p>
|
<p style={{ margin: '8px 0 0 0', fontSize: '14px' }}>{tg.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -892,6 +879,224 @@ export default function AdminCatalogsPage() {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,5 @@ export const PAGE_VERSIONS = {
|
||||||
ClubsPage: "1.0.0",
|
ClubsPage: "1.0.0",
|
||||||
SkillsPage: "1.0.0",
|
SkillsPage: "1.0.0",
|
||||||
TrainingPlanningPage: "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