feat: complete admin system - global catalogs + M:N assignments
Tab System: - Tab 1: Hierarchie (Fokusbereich → Stilrichtung/Trainingstyp) - Tab 2: Kataloge (Zielgruppen, Fähigkeiten, Trainingscharakter) - Tab 3: Zuordnungen (Matrix Stilrichtungen ↔ Zielgruppen) Global Catalogs (Tab 2): - Zielgruppen: CRUD mit Altersangaben (zentral verwaltet) - Fähigkeitskategorien: CRUD (global) - Trainingscharakter: CRUD (global) - Reusable CatalogSection component with dynamic fields - Create/Edit/Delete für alle Kataloge - Inline editing + validation M:N Assignments Matrix (Tab 3): - Checkbox-Grid: Stilrichtungen (Zeilen) × Zielgruppen (Spalten) - Grouped by Focus Area for clarity - Toggle assignments with immediate save - Shows empty states with helpful messages - Fully responsive table with horizontal scroll Architecture: - Trainingstypen: Context-specific per focus area (create new) - Zielgruppen: Global catalog (assign via matrix) - Clean separation of concerns - Proper loading states + error handling Mobile responsive across all tabs. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
80986735b5
commit
3fda149049
|
|
@ -13,15 +13,31 @@ import { api } from '../utils/api'
|
|||
*/
|
||||
|
||||
function AdminHierarchyPage() {
|
||||
const [activeTab, setActiveTab] = useState('hierarchy')
|
||||
const [hierarchy, setHierarchy] = useState([])
|
||||
const [expandedNodes, setExpandedNodes] = useState(new Set())
|
||||
const [selectedItem, setSelectedItem] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Global catalogs state
|
||||
const [targetGroups, setTargetGroups] = useState([])
|
||||
const [skillCategories, setSkillCategories] = useState([])
|
||||
const [trainingCharacters, setTrainingCharacters] = useState([])
|
||||
|
||||
// Matrix state
|
||||
const [styleDirections, setStyleDirections] = useState([])
|
||||
const [assignments, setAssignments] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
loadHierarchy()
|
||||
}, [])
|
||||
if (activeTab === 'hierarchy') {
|
||||
loadHierarchy()
|
||||
} else if (activeTab === 'catalogs') {
|
||||
loadCatalogs()
|
||||
} else if (activeTab === 'assignments') {
|
||||
loadAssignments()
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
async function loadHierarchy() {
|
||||
setLoading(true)
|
||||
|
|
@ -40,6 +56,44 @@ function AdminHierarchyPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadCatalogs() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [tgs, scs, tcs] = await Promise.all([
|
||||
api.listTargetGroups(),
|
||||
api.listSkillCategories(),
|
||||
api.listTrainingCharacters()
|
||||
])
|
||||
setTargetGroups(tgs)
|
||||
setSkillCategories(scs)
|
||||
setTrainingCharacters(tcs)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssignments() {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [sds, tgs, assigns] = await Promise.all([
|
||||
api.listStyleDirections(),
|
||||
api.listTargetGroups(),
|
||||
api.listStyleDirectionTargetGroups()
|
||||
])
|
||||
setStyleDirections(sds)
|
||||
setTargetGroups(tgs)
|
||||
setAssignments(assigns)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNode(nodeId) {
|
||||
const newExpanded = new Set(expandedNodes)
|
||||
if (newExpanded.has(nodeId)) {
|
||||
|
|
@ -90,9 +144,119 @@ function AdminHierarchyPage() {
|
|||
.admin-tree-view {
|
||||
${selectedItem ? 'display: none;' : 'display: block;'}
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 2px solid var(--border);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
padding: 12px 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.admin-tab:hover {
|
||||
color: var(--text1);
|
||||
background: var(--surface2);
|
||||
}
|
||||
|
||||
.admin-tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="admin-hierarchy-container">
|
||||
<div style={{ padding: '16px', paddingBottom: '80px' }}>
|
||||
{/* Tabs */}
|
||||
<div className="admin-tabs">
|
||||
<button
|
||||
className={`admin-tab ${activeTab === 'hierarchy' ? 'active' : ''}`}
|
||||
onClick={() => { setActiveTab('hierarchy'); setSelectedItem(null) }}
|
||||
>
|
||||
Hierarchie
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab ${activeTab === 'catalogs' ? 'active' : ''}`}
|
||||
onClick={() => { setActiveTab('catalogs'); setSelectedItem(null) }}
|
||||
>
|
||||
Kataloge
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab ${activeTab === 'assignments' ? 'active' : ''}`}
|
||||
onClick={() => { setActiveTab('assignments'); setSelectedItem(null) }}
|
||||
>
|
||||
Zuordnungen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'hierarchy' && (
|
||||
<HierarchyTab
|
||||
hierarchy={hierarchy}
|
||||
expandedNodes={expandedNodes}
|
||||
selectedItem={selectedItem}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onToggleNode={(nodeId) => {
|
||||
const newExpanded = new Set(expandedNodes)
|
||||
if (newExpanded.has(nodeId)) {
|
||||
newExpanded.delete(nodeId)
|
||||
} else {
|
||||
newExpanded.add(nodeId)
|
||||
}
|
||||
setExpandedNodes(newExpanded)
|
||||
}}
|
||||
onSelectItem={setSelectedItem}
|
||||
onUpdate={loadHierarchy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'catalogs' && (
|
||||
<CatalogsTab
|
||||
targetGroups={targetGroups}
|
||||
skillCategories={skillCategories}
|
||||
trainingCharacters={trainingCharacters}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onUpdate={loadCatalogs}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'assignments' && (
|
||||
<AssignmentsTab
|
||||
styleDirections={styleDirections}
|
||||
targetGroups={targetGroups}
|
||||
assignments={assignments}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onUpdate={loadAssignments}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tab 1: Hierarchy
|
||||
// ============================================================================
|
||||
|
||||
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="admin-hierarchy-container">
|
||||
{/* Tree View */}
|
||||
<div
|
||||
className="admin-tree-view"
|
||||
|
|
@ -131,16 +295,196 @@ function AdminHierarchyPage() {
|
|||
>
|
||||
<button
|
||||
className="btn admin-back-button"
|
||||
onClick={() => setSelectedItem(null)}
|
||||
onClick={() => onSelectItem(null)}
|
||||
style={{ marginBottom: '16px' }}
|
||||
>
|
||||
← Zurück zur Übersicht
|
||||
</button>
|
||||
<DetailPanel item={selectedItem} onUpdate={loadHierarchy} focusAreas={hierarchy} />
|
||||
<DetailPanel item={selectedItem} onUpdate={onUpdate} focusAreas={hierarchy} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tab 2: Global Catalogs
|
||||
// ============================================================================
|
||||
|
||||
function CatalogsTab({ targetGroups, skillCategories, trainingCharacters, loading, error, onUpdate }) {
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></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>}
|
||||
|
||||
<CatalogSection
|
||||
title="Zielgruppen"
|
||||
icon="🎯"
|
||||
items={targetGroups}
|
||||
onUpdate={onUpdate}
|
||||
createFn={api.createTargetGroup}
|
||||
updateFn={api.updateTargetGroup}
|
||||
deleteFn={api.deleteTargetGroup}
|
||||
fields={[
|
||||
{ key: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'textarea' },
|
||||
{ key: 'min_age', label: 'Min. Alter', type: 'number' },
|
||||
{ key: 'max_age', label: 'Max. Alter', type: 'number' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<CatalogSection
|
||||
title="Fähigkeitskategorien"
|
||||
icon="⚡"
|
||||
items={skillCategories}
|
||||
onUpdate={onUpdate}
|
||||
createFn={api.createSkillCategory}
|
||||
updateFn={api.updateSkillCategory}
|
||||
deleteFn={api.deleteSkillCategory}
|
||||
fields={[
|
||||
{ key: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'textarea' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<CatalogSection
|
||||
title="Trainingscharakter"
|
||||
icon="💪"
|
||||
items={trainingCharacters}
|
||||
onUpdate={onUpdate}
|
||||
createFn={api.createTrainingCharacter}
|
||||
updateFn={api.updateTrainingCharacter}
|
||||
deleteFn={api.deleteTrainingCharacter}
|
||||
fields={[
|
||||
{ key: 'name', label: 'Name', type: 'text', required: true },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'textarea' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tab 3: Assignments Matrix
|
||||
// ============================================================================
|
||||
|
||||
function AssignmentsTab({ styleDirections, targetGroups, assignments, loading, error, onUpdate }) {
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: '40px' }}><div className="spinner"></div></div>
|
||||
}
|
||||
|
||||
async function toggleAssignment(styleDirectionId, targetGroupId) {
|
||||
const existing = assignments.find(
|
||||
a => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
|
||||
)
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
if (existing) {
|
||||
await api.deleteStyleDirectionTargetGroup(existing.id)
|
||||
} else {
|
||||
await api.createStyleDirectionTargetGroup({
|
||||
style_direction_id: styleDirectionId,
|
||||
target_group_id: targetGroupId,
|
||||
is_primary: false
|
||||
})
|
||||
}
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function isAssigned(styleDirectionId, targetGroupId) {
|
||||
return assignments.some(
|
||||
a => a.style_direction_id === styleDirectionId && a.target_group_id === targetGroupId
|
||||
)
|
||||
}
|
||||
|
||||
// Group style directions by focus area
|
||||
const groupedStyles = styleDirections.reduce((acc, sd) => {
|
||||
const key = sd.focus_area_name || 'Ohne Fokusbereich'
|
||||
if (!acc[key]) acc[key] = []
|
||||
acc[key].push(sd)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{error && <div style={{ color: 'var(--danger)', marginBottom: '16px' }}>{error}</div>}
|
||||
|
||||
<div style={{ background: 'var(--surface)', borderRadius: '12px', padding: '20px', overflowX: 'auto' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Stilrichtungen ↔ Zielgruppen</h2>
|
||||
<p style={{ color: 'var(--text2)', marginBottom: '24px' }}>
|
||||
Ordne Zielgruppen den Stilrichtungen zu (mehrere möglich)
|
||||
</p>
|
||||
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', minWidth: '600px' }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left', padding: '12px', borderBottom: '2px solid var(--border)' }}>
|
||||
Stilrichtung
|
||||
</th>
|
||||
{targetGroups.map(tg => (
|
||||
<th key={tg.id} style={{ textAlign: 'center', padding: '12px', borderBottom: '2px solid var(--border)', minWidth: '100px' }}>
|
||||
{tg.name}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(groupedStyles).map(([focusArea, styles]) => (
|
||||
<React.Fragment key={focusArea}>
|
||||
<tr>
|
||||
<td colSpan={targetGroups.length + 1} style={{ padding: '16px 12px 8px', fontWeight: 600, fontSize: '14px', color: 'var(--text2)', textTransform: 'uppercase' }}>
|
||||
{focusArea}
|
||||
</td>
|
||||
</tr>
|
||||
{styles.map(sd => (
|
||||
<tr key={sd.id}>
|
||||
<td style={{ padding: '12px', borderBottom: '1px solid var(--border)' }}>
|
||||
{sd.name}
|
||||
{sd.abbreviation && <span style={{ color: 'var(--text3)', marginLeft: '8px' }}>({sd.abbreviation})</span>}
|
||||
</td>
|
||||
{targetGroups.map(tg => (
|
||||
<td key={tg.id} style={{ textAlign: 'center', padding: '12px', borderBottom: '1px solid var(--border)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAssigned(sd.id, tg.id)}
|
||||
onChange={() => toggleAssignment(sd.id, tg.id)}
|
||||
disabled={saving}
|
||||
style={{ cursor: 'pointer', width: '20px', height: '20px' }}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{styleDirections.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text2)', padding: '40px' }}>
|
||||
Keine Stilrichtungen vorhanden. Erstelle zuerst Stilrichtungen in der Hierarchie.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{targetGroups.length === 0 && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text2)', padding: '40px' }}>
|
||||
Keine Zielgruppen vorhanden. Erstelle zuerst Zielgruppen im Kataloge-Tab.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -719,4 +1063,176 @@ function CreateTrainingTypeForm({ context, onUpdate }) {
|
|||
)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Reusable Catalog Section Component
|
||||
// ============================================================================
|
||||
|
||||
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] = ''
|
||||
})
|
||||
setForm(emptyForm)
|
||||
setCreating(true)
|
||||
}
|
||||
|
||||
function startEdit(item) {
|
||||
const editForm = {}
|
||||
fields.forEach(f => {
|
||||
editForm[f.key] = item[f.key] || ''
|
||||
})
|
||||
setEditing(item.id)
|
||||
setForm(editForm)
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const required = fields.filter(f => f.required)
|
||||
for (const field of required) {
|
||||
if (!form[field.key]) {
|
||||
alert(`${field.label} ist erforderlich`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await createFn(form)
|
||||
setCreating(false)
|
||||
setForm({})
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(id) {
|
||||
try {
|
||||
await updateFn(id, form)
|
||||
setEditing(null)
|
||||
setForm({})
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id, name) {
|
||||
if (!confirm(`"${name}" wirklich löschen?`)) return
|
||||
try {
|
||||
await deleteFn(id)
|
||||
onUpdate()
|
||||
} catch (e) {
|
||||
alert('Fehler: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{/* Create Form */}
|
||||
{creating && (
|
||||
<div style={{ marginBottom: '20px', padding: '16px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
||||
<h4 style={{ marginTop: 0 }}>Neu erstellen</h4>
|
||||
{fields.map(field => (
|
||||
<div key={field.key} className="form-row">
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Items List */}
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{items.map(item => (
|
||||
<div key={item.id} style={{ padding: '12px', background: 'var(--surface2)', borderRadius: '8px' }}>
|
||||
{editing === item.id ? (
|
||||
// Edit Mode
|
||||
<div>
|
||||
{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}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
// View Mode
|
||||
<div>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
<strong>{item.name}</strong>
|
||||
{item.min_age !== null && item.max_age !== null && (
|
||||
<span style={{ marginLeft: '12px', color: 'var(--text3)', fontSize: '14px' }}>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{items.length === 0 && !creating && (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text3)', padding: '20px' }}>
|
||||
Noch keine Einträge vorhanden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminHierarchyPage
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user