feat: enhance SQL query handling and UI components for exercise management
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Has been cancelled

- Introduced a new utility function to streamline SQL query construction for active status filtering, improving code reusability across multiple endpoints.
- Updated existing query logic in the catalogs and skills routers to utilize the new utility function, ensuring consistent handling of active status.
- Refactored the ExerciseFormPage to remove deprecated age group handling, simplifying the form structure.
- Enhanced the RichTextEditor component with improved link handling and formatting options for better user experience.
- Updated API utility functions to support new AI features for exercise suggestions and regeneration, expanding the capabilities of the exercise management system.
This commit is contained in:
Lars 2026-04-27 15:01:47 +02:00
parent d8f439a3e5
commit 0ad096e483
7 changed files with 129 additions and 84 deletions

View File

@ -14,6 +14,17 @@ from auth import require_auth
router = APIRouter(prefix="/api", tags=["catalogs"])
def _sql_active_status(column: str, status: Optional[str]) -> tuple[str, list]:
"""
Filter active schließt Legacy-Zeilen mit status IS NULL ein (sonst leere Dropdowns in der UI).
"""
if not status:
return "", []
if status == "active":
return f" ({column} = 'active' OR {column} IS NULL)", []
return f" ({column} = %s)", [status]
def _slugify_skill_label(text: str) -> str:
t = (text or "").strip().lower()
t = re.sub(r"[^a-z0-9äöüß]+", "_", t, flags=re.IGNORECASE)
@ -155,9 +166,10 @@ def list_focus_areas(
query = "SELECT * FROM focus_areas"
params = []
if status:
query += " WHERE status = %s"
params.append(status)
frag, extra = _sql_active_status("status", status)
if frag:
query += " WHERE" + frag
params.extend(extra)
query += " ORDER BY sort_order, name"
@ -275,9 +287,10 @@ def list_training_styles(
"""
params = []
if status:
query += " WHERE ts.status = %s"
params.append(status)
frag, extra = _sql_active_status("ts.status", status)
if frag:
query += " WHERE" + frag
params.extend(extra)
query += " ORDER BY ts.sort_order, ts.name"
@ -517,9 +530,10 @@ def list_training_types(
params = []
where = []
if status:
where.append("tt.status = %s")
params.append(status)
frag, extra = _sql_active_status("tt.status", status)
if frag:
where.append(frag.strip())
params.extend(extra)
if focus_area_id is not None:
where.append("tt.focus_area_id = %s")
@ -955,9 +969,10 @@ def list_target_groups(
params = []
where = []
if status:
where.append("status = %s")
params.append(status)
frag, extra = _sql_active_status("status", status)
if frag:
where.append(frag.strip())
params.extend(extra)
if where:
query += " WHERE " + " AND ".join(where)

View File

@ -53,11 +53,14 @@ def list_skills(
params.append(category)
if status:
where.append("status = %s")
params.append(status)
if status == "active":
where.append("(status = 'active' OR status IS NULL)")
else:
where.append("status = %s")
params.append(status)
else:
# Default: only active skills
where.append("status = 'active'")
# Default: nur aktive (NULL = Legacy, wie Kataloglisten)
where.append("(status = 'active' OR status IS NULL)")
if where:
query += " WHERE " + " AND ".join(where)

View File

@ -2258,8 +2258,10 @@ a.analysis-split__nav-item {
outline: none;
font-size: 15px;
line-height: 1.5;
max-height: 50vh;
min-height: 120px;
max-height: min(70vh, 520px);
overflow-y: auto;
resize: vertical;
}
.rich-text-editor:empty:before {
content: attr(data-placeholder);

View File

@ -2,76 +2,103 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'
function exec(cmd, value = null) {
try {
document.execCommand(cmd, false, value)
return document.execCommand(cmd, false, value)
} catch (_) {
/* ignore */
return false
}
}
/** Browser: formatBlock erwartet oft Tag in Großschreibung. */
function formatBlock(tag) {
const t = String(tag).toUpperCase()
if (!exec('formatBlock', t)) {
exec('formatBlock', tag.toLowerCase())
}
}
function normalText() {
exec('removeFormat')
formatBlock('p')
}
/**
* Leichter WYSIWYG-Editor (contenteditable) ohne zusätzliche npm-Pakete.
* Speichert HTML; Anzeige über sanitizeTrainerHtml + dangerouslySetInnerHTML.
* Leichter WYSIWYG (contenteditable). Wert kommt von außen zuverlässig ins DOM (Edit-Modus).
*/
export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px' }) {
const ref = useRef(null)
const [focused, setFocused] = useState(false)
const lastExternal = useRef(value)
useEffect(() => {
if (!ref.current) return
if (focused) return
if (value !== lastExternal.current) {
lastExternal.current = value
if (ref.current.innerHTML !== (value || '')) {
ref.current.innerHTML = value || ''
}
const el = ref.current
if (!el || focused) return
const next = value ?? ''
if (el.innerHTML !== next) {
el.innerHTML = next
}
}, [value, focused])
const sync = useCallback(() => {
if (!ref.current) return
const html = ref.current.innerHTML
lastExternal.current = html
onChange(html)
onChange(ref.current.innerHTML)
}, [onChange])
const onLink = () => {
const url = window.prompt('Link-URL (https://…)')
if (url) exec('createLink', url)
const run = (fn) => (e) => {
e.preventDefault()
ref.current?.focus()
fn()
sync()
}
const onLink = (e) => {
e.preventDefault()
ref.current?.focus()
const url = window.prompt('Link-URL (https://…)')
if (url) {
exec('createLink', url)
sync()
}
}
return (
<div className="rich-text-editor-wrap">
<div className="rich-text-toolbar" role="toolbar" aria-label="Formatierung">
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('bold')}>
<button type="button" className="rte-btn" title="Fett" onMouseDown={run(() => exec('bold'))}>
<strong>B</strong>
</button>
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('italic')}>
<button type="button" className="rte-btn" title="Kursiv" onMouseDown={run(() => exec('italic'))}>
<em>I</em>
</button>
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('underline')}>
<button type="button" className="rte-btn" title="Unterstrichen" onMouseDown={run(() => exec('underline'))}>
U
</button>
<span className="rte-sep" />
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('formatBlock', 'h3')}>
H3
<button type="button" className="rte-btn" title="Normaler Absatz" onMouseDown={run(normalText)}>
Normal
</button>
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('insertUnorderedList')}>
</button>
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('insertOrderedList')}>
1.
</button>
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('formatBlock', 'p')}>
<button type="button" className="rte-btn" title="Zwischenüberschrift" onMouseDown={run(() => formatBlock('h3'))}>
Ü3
</button>
<span className="rte-sep" />
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={onLink}>
<button type="button" className="rte-btn" title="Aufzählung" onMouseDown={run(() => exec('insertUnorderedList'))}>
Liste
</button>
<button type="button" className="rte-btn" title="Nummerierte Liste" onMouseDown={run(() => exec('insertOrderedList'))}>
1. Liste
</button>
<span className="rte-sep" />
<button type="button" className="rte-btn" title="Link einfügen" onMouseDown={onLink}>
Link
</button>
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('removeFormat')}>
<button
type="button"
className="rte-btn"
title="Zeichenformatierung am Cursor entfernen"
onMouseDown={run(() => {
exec('removeFormat')
exec('unlink')
})}
>
</button>
</div>
<div

View File

@ -73,9 +73,6 @@ function TagRow({ exercise }) {
;(exercise.target_groups || []).forEach((g) => {
tags.push({ key: `tg-${g.id}`, label: g.name, accent: !!g.is_primary })
})
;(exercise.age_groups || []).forEach((ag) => {
tags.push({ key: `ag-${ag}`, label: ag, accent: false })
})
if (tags.length === 0) return null
return (
<div className="exercise-tag-row">

View File

@ -19,8 +19,6 @@ const LEVEL_OPTIONS = [
{ value: 'experte', label: 'Experte' },
]
const AGE_GROUP_OPTIONS = ['Minis', 'Kinder', 'Schüler', 'Teenager', 'Erwachsene']
function emptyForm() {
return {
title: '',
@ -34,7 +32,6 @@ function emptyForm() {
duration_max: '',
group_size_min: '',
group_size_max: '',
age_groups: [],
focus_areas_multi: [],
training_styles_multi: [],
training_types_multi: [],
@ -58,7 +55,6 @@ function detailToForm(exercise) {
duration_max: exercise.duration_max ?? '',
group_size_min: exercise.group_size_min ?? '',
group_size_max: exercise.group_size_max ?? '',
age_groups: exercise.age_groups || [],
focus_areas_multi: (exercise.focus_areas || []).map((f) => ({
focus_area_id: f.focus_area_id,
is_primary: !!f.is_primary,
@ -195,7 +191,13 @@ function ExerciseFormPage() {
setTrainingTypes(ttData)
setTargetGroups(tgData)
} catch (e) {
if (!cancelled) console.error(e)
if (!cancelled) {
console.error(e)
alert(
'Kataloge (Fokus, Stile, Zielgruppen, Fähigkeiten) konnten nicht geladen werden: ' +
(e.message || e),
)
}
}
}
boot()
@ -238,13 +240,6 @@ function ExerciseFormPage() {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const toggleAgeGroup = (name) => {
const set = new Set(formData.age_groups)
if (set.has(name)) set.delete(name)
else set.add(name)
updateFormField('age_groups', [...set])
}
const addSkillRow = () => {
const id = skillPick ? parseInt(skillPick, 10) : null
if (!id) {
@ -545,22 +540,6 @@ function ExerciseFormPage() {
</div>
</div>
<div className="form-row">
<label className="form-label">Altersgruppen (Katalog)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{AGE_GROUP_OPTIONS.map((ag) => (
<label key={ag} style={{ fontSize: '14px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={formData.age_groups.includes(ag)}
onChange={() => toggleAgeGroup(ag)}
/>
{ag}
</label>
))}
</div>
</div>
<MultiAssocBlock
title="Fokusbereiche (0…n, ein „primär“)"
rows={formData.focus_areas_multi}
@ -812,7 +791,12 @@ function ExerciseFormPage() {
)}
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
Varianten-Editor folgt in einem späteren Schritt (API ist teilweise vorhanden).
Varianten-Editor folgt später (API teilweise vorhanden).{' '}
<strong>KI-Ausbaustufe:</strong> Backend laut Spec{' '}
<code style={{ fontSize: '11px' }}>POST /api/exercises/ai/suggest</code> und{' '}
<code style={{ fontSize: '11px' }}>POST /api/exercises/{'{id}'}/ai/regenerate</code> z. B.{' '}
<code>OPENROUTER_API_KEY</code>, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
<code>api.suggestExerciseAi</code>).
</p>
</div>
)

View File

@ -263,7 +263,7 @@ export function buildExerciseApiPayload(formData) {
training_styles_multi: mapStyles,
training_types_multi: mapTTypes,
target_groups_multi: mapTg,
age_groups: formData.age_groups || [],
age_groups: [],
skills: (formData.skills || []).map((s) => ({
skill_id: s.skill_id,
is_primary: !!s.is_primary,
@ -340,6 +340,21 @@ export async function deleteExercise(id) {
return request(`/api/exercises/${id}`, { method: 'DELETE' })
}
/** KI-Ausbaustufe (EXERCISES_API_SPEC): benötigt Backend + z. B. OPENROUTER_API_KEY. */
export async function suggestExerciseAi(payload) {
return request('/api/exercises/ai/suggest', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function regenerateExerciseAi(exerciseId, payload) {
return request(`/api/exercises/${exerciseId}/ai/regenerate`, {
method: 'POST',
body: JSON.stringify(payload),
})
}
// ============================================================================
// Catalogs (Admin-verwaltbare Stammdaten)
// ============================================================================
@ -785,6 +800,8 @@ export const api = {
updateExercise,
deleteExercise,
buildExerciseApiPayload,
suggestExerciseAi,
regenerateExerciseAi,
uploadExerciseMedia,
updateExerciseMedia,
deleteExerciseMedia,