feat: Zielgruppen-Verwaltung (Target Groups CRUD + Admin UI)
Some checks failed
Deploy Development / deploy (push) Successful in 32s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m54s

Backend:
- GET /api/target-groups: Liste mit hierarchischem Kontext (focus_area → training_style → target_group)
- POST /api/target-groups: Create (admin only)
- PUT /api/target-groups/{id}: Update (admin only)
- DELETE /api/target-groups/{id}: Delete (superadmin only) mit CASCADE-Schutz
- Filter: by training_style_id, status

Frontend:
- api.js: listTargetGroups, createTargetGroup, updateTargetGroup, deleteTargetGroup
- AdminCatalogsPage: Neuer Tab "Zielgruppen" (6. Tab)
- Create-Form: training_style_id, name, description, min_age, max_age
- List-View: Hierarchie-Anzeige (Fokusbereich → Stil → Zielgruppe + Altersbereich)
- Inline-Editing mit Stil-Auswahl-Dropdown
- Delete mit Confirmation Dialog

Architektur:
- Hierarchische Beziehung: target_groups.training_style_id → training_styles → focus_areas
- CASCADE-Protection: DELETE verweigert wenn exercise_target_groups Einträge existieren
- Backend liefert enriched data mit training_style_name + focus_area_name

version: 0.3.2
modules: catalogs 1.2.0
pages: AdminCatalogsPage 1.1.0
This commit is contained in:
Lars 2026-04-23 08:55:54 +02:00
parent d67f659e97
commit 278d719e84
5 changed files with 416 additions and 4 deletions

View File

@ -576,3 +576,174 @@ def delete_trainer_focus_area(tfa_id: int, session=Depends(require_auth)):
conn.commit()
return {"ok": True}
# ════════════════════════════════════════════════════════════════════════
# TARGET GROUPS (Zielgruppen)
# ════════════════════════════════════════════════════════════════════════
@router.get("/target-groups")
def list_target_groups(
status: Optional[str] = Query(default='active'),
training_style_id: Optional[int] = Query(default=None),
session=Depends(require_auth)
):
"""List all target groups with hierarchical context."""
with get_db() as conn:
cur = get_cursor(conn)
query = """
SELECT tg.*,
ts.name as training_style_name,
fa.name as focus_area_name
FROM target_groups tg
LEFT JOIN training_styles ts ON tg.training_style_id = ts.id
LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id
"""
params = []
where = []
if status:
where.append("tg.status = %s")
params.append(status)
if training_style_id:
where.append("tg.training_style_id = %s")
params.append(training_style_id)
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY fa.name, ts.name, tg.sort_order, tg.name"
cur.execute(query, params)
rows = cur.fetchall()
return [r2d(r) for r in rows]
@router.post("/target-groups")
def create_target_group(data: dict, session=Depends(require_auth)):
"""Create new target group (admin only)."""
role = session.get('role')
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Zielgruppen erstellen")
name = data.get('name')
if not name:
raise HTTPException(400, "Name ist Pflichtfeld")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
INSERT INTO target_groups (
training_style_id, name, description,
min_age, max_age, sort_order, status
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
data.get('training_style_id'),
name,
data.get('description'),
data.get('min_age'),
data.get('max_age'),
data.get('sort_order', 99),
data.get('status', 'active')
))
target_group_id = cur.fetchone()['id']
conn.commit()
# Return with hierarchical context
cur.execute("""
SELECT tg.*,
ts.name as training_style_name,
fa.name as focus_area_name
FROM target_groups tg
LEFT JOIN training_styles ts ON tg.training_style_id = ts.id
LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id
WHERE tg.id = %s
""", (target_group_id,))
return r2d(cur.fetchone())
@router.put("/target-groups/{target_group_id}")
def update_target_group(target_group_id: int, data: dict, session=Depends(require_auth)):
"""Update target group (admin only)."""
role = session.get('role')
if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Zielgruppen bearbeiten")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("""
UPDATE target_groups SET
training_style_id = %s,
name = %s,
description = %s,
min_age = %s,
max_age = %s,
sort_order = %s,
status = %s,
updated_at = NOW()
WHERE id = %s
""", (
data.get('training_style_id'),
data.get('name'),
data.get('description'),
data.get('min_age'),
data.get('max_age'),
data.get('sort_order'),
data.get('status'),
target_group_id
))
conn.commit()
# Return with hierarchical context
cur.execute("""
SELECT tg.*,
ts.name as training_style_name,
fa.name as focus_area_name
FROM target_groups tg
LEFT JOIN training_styles ts ON tg.training_style_id = ts.id
LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id
WHERE tg.id = %s
""", (target_group_id,))
return r2d(cur.fetchone())
@router.delete("/target-groups/{target_group_id}")
def delete_target_group(target_group_id: int, session=Depends(require_auth)):
"""Delete target group (superadmin only).
Fails if target group is assigned to any exercises (CASCADE protection).
"""
role = session.get('role')
if role != 'superadmin':
raise HTTPException(403, "Nur Superadmins dürfen Zielgruppen löschen")
with get_db() as conn:
cur = get_cursor(conn)
# Check if assigned to exercises
cur.execute("""
SELECT COUNT(*) as count
FROM exercise_target_groups
WHERE target_group_id = %s
""", (target_group_id,))
count = cur.fetchone()['count']
if count > 0:
raise HTTPException(
409,
f"Zielgruppe kann nicht gelöscht werden: {count} Übung(en) zugeordnet. "
"Bitte zuerst alle Zuordnungen entfernen oder umrouten."
)
cur.execute("DELETE FROM target_groups WHERE id = %s", (target_group_id,))
conn.commit()
return {"ok": True}

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.3.1"
APP_VERSION = "0.3.2"
BUILD_DATE = "2026-04-23"
DB_SCHEMA_VERSION = "20260423"
@ -18,10 +18,22 @@ MODULE_VERSIONS = {
"import_wiki": "0.1.0",
"admin": "1.0.0",
"membership": "1.0.0",
"catalogs": "1.1.0", # Updated: Zielgruppen + Hierarchie
"catalogs": "1.2.0", # Updated: Target Groups CRUD + Admin UI
}
CHANGELOG = [
{
"version": "0.3.2",
"date": "2026-04-23",
"changes": [
"Feature: Zielgruppen-Verwaltung komplett (Backend + Frontend)",
"API: GET/POST/PUT/DELETE /target-groups mit hierarchischem Kontext (focus_area → training_style → target_group)",
"Admin UI: Neuer Tab 'Zielgruppen' in Katalogverwaltung",
"UX: Create/Edit-Forms mit Training-Stil-Auswahl, Altersbereich (min/max)",
"UX: Hierarchie-Anzeige in Liste (Fokusbereich → Stil → Zielgruppe)",
"Protection: DELETE mit CASCADE-Schutz (Fehler wenn Übungen zugeordnet)",
]
},
{
"version": "0.3.1",
"date": "2026-04-23",

View File

@ -26,6 +26,11 @@ export default function AdminCatalogsPage() {
const [editingSC, setEditingSC] = useState(null)
const [newSC, setNewSC] = useState({ name: '', description: '', parent_category_id: null })
// Target Groups
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 })
// Trainer Focus Areas
const [trainerAssignments, setTrainerAssignments] = useState([])
const [profiles, setProfiles] = useState([])
@ -51,6 +56,13 @@ export default function AdminCatalogsPage() {
} else if (activeTab === 'skill-categories') {
const data = await api.listSkillCategories()
setSkillCategories(data)
} else if (activeTab === 'target-groups') {
const [groups, styles] = await Promise.all([
api.listTargetGroups(),
api.listTrainingStyles()
])
setTargetGroups(groups)
setTrainingStyles(styles)
} else if (activeTab === 'trainer-assignments') {
const [assignments, profs, areas] = await Promise.all([
api.listTrainerFocusAreas(),
@ -192,6 +204,37 @@ export default function AdminCatalogsPage() {
}
}
// Target Groups
async function createTargetGroup() {
try {
await api.createTargetGroup(newTG)
setNewTG({ name: '', description: '', training_style_id: null, min_age: null, max_age: null })
loadData()
} catch (e) {
setError(e.message)
}
}
async function updateTargetGroup(id, data) {
try {
await api.updateTargetGroup(id, data)
setEditingTG(null)
loadData()
} catch (e) {
setError(e.message)
}
}
async function deleteTargetGroup(id) {
if (!confirm('Zielgruppe wirklich löschen?')) return
try {
await api.deleteTargetGroup(id)
loadData()
} catch (e) {
setError(e.message)
}
}
// Trainer Assignments
async function assignTrainer() {
try {
@ -224,6 +267,7 @@ export default function AdminCatalogsPage() {
{ id: 'training-styles', label: 'Trainingsstile' },
{ 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
@ -640,6 +684,163 @@ export default function AdminCatalogsPage() {
</div>
)}
{/* Target Groups */}
{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>
<div className="form-row">
<label className="form-label">Name</label>
<input
className="form-input"
value={newTG.name}
onChange={e => setNewTG({ ...newTG, name: e.target.value })}
placeholder="z.B. Breitensportler"
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung (optional)</label>
<textarea
className="form-input"
value={newTG.description || ''}
onChange={e => setNewTG({ ...newTG, description: e.target.value })}
rows="3"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-row">
<label className="form-label">Min. Alter (optional)</label>
<input
type="number"
className="form-input"
value={newTG.min_age || ''}
onChange={e => setNewTG({ ...newTG, min_age: e.target.value ? parseInt(e.target.value) : null })}
/>
</div>
<div className="form-row">
<label className="form-label">Max. Alter (optional)</label>
<input
type="number"
className="form-input"
value={newTG.max_age || ''}
onChange={e => setNewTG({ ...newTG, max_age: e.target.value ? parseInt(e.target.value) : null })}
/>
</div>
</div>
<button className="btn btn-primary" onClick={createTargetGroup}>Erstellen</button>
</div>
<div style={{ display: 'grid', gap: '16px' }}>
{targetGroups.map(tg => (
<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
className="form-input"
value={tg.name}
onChange={e => setTargetGroups(targetGroups.map(x =>
x.id === tg.id ? { ...x, name: e.target.value } : x
))}
/>
</div>
<div className="form-row">
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
value={tg.description || ''}
onChange={e => setTargetGroups(targetGroups.map(x =>
x.id === tg.id ? { ...x, description: e.target.value } : x
))}
rows="3"
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div className="form-row">
<label className="form-label">Min. Alter</label>
<input
type="number"
className="form-input"
value={tg.min_age || ''}
onChange={e => setTargetGroups(targetGroups.map(x =>
x.id === tg.id ? { ...x, min_age: e.target.value ? parseInt(e.target.value) : null } : x
))}
/>
</div>
<div className="form-row">
<label className="form-label">Max. Alter</label>
<input
type="number"
className="form-input"
value={tg.max_age || ''}
onChange={e => setTargetGroups(targetGroups.map(x =>
x.id === tg.id ? { ...x, max_age: e.target.value ? parseInt(e.target.value) : null } : x
))}
/>
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
<button className="btn btn-primary" onClick={() => updateTargetGroup(tg.id, tg)}>Speichern</button>
<button className="btn" onClick={() => setEditingTG(null)}>Abbrechen</button>
</div>
</div>
) : (
<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.description && (
<p style={{ margin: '8px 0 0 0', fontSize: '14px' }}>{tg.description}</p>
)}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn" onClick={() => setEditingTG(tg.id)}>Bearbeiten</button>
<button className="btn" style={{ background: 'var(--danger)', color: 'white' }} onClick={() => deleteTargetGroup(tg.id)}>Löschen</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Trainer Assignments */}
{activeTab === 'trainer-assignments' && (
<div>

View File

@ -346,6 +346,30 @@ export async function deleteTrainerFocusArea(id) {
return request(`/api/trainer-focus-areas/${id}`, { method: 'DELETE' })
}
// Target Groups (Zielgruppen)
export async function listTargetGroups(filters = {}) {
const query = new URLSearchParams(filters).toString()
return request(`/api/target-groups${query ? '?' + query : ''}`)
}
export async function createTargetGroup(data) {
return request('/api/target-groups', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function updateTargetGroup(id, data) {
return request(`/api/target-groups/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
})
}
export async function deleteTargetGroup(id) {
return request(`/api/target-groups/${id}`, { method: 'DELETE' })
}
// ============================================================================
// Training Planning
// ============================================================================
@ -466,6 +490,10 @@ export const api = {
listTrainerFocusAreas,
assignTrainerFocusArea,
deleteTrainerFocusArea,
listTargetGroups,
createTargetGroup,
updateTargetGroup,
deleteTargetGroup,
// System
getVersion,

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.3.1"
export const APP_VERSION = "0.3.2"
export const BUILD_DATE = "2026-04-23"
export const PAGE_VERSIONS = {
@ -11,5 +11,5 @@ export const PAGE_VERSIONS = {
ClubsPage: "1.0.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.0.0",
AdminCatalogsPage: "1.0.0", // NEW
AdminCatalogsPage: "1.1.0", // Updated: Target Groups tab
}