feat: Zielgruppen-Verwaltung (Target Groups CRUD + Admin UI)
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:
parent
d67f659e97
commit
278d719e84
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user