feat: enhance SQL query handling and UI components for exercise management
- 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:
parent
d8f439a3e5
commit
0ad096e483
|
|
@ -14,6 +14,17 @@ from auth import require_auth
|
||||||
router = APIRouter(prefix="/api", tags=["catalogs"])
|
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:
|
def _slugify_skill_label(text: str) -> str:
|
||||||
t = (text or "").strip().lower()
|
t = (text or "").strip().lower()
|
||||||
t = re.sub(r"[^a-z0-9äöüß]+", "_", t, flags=re.IGNORECASE)
|
t = re.sub(r"[^a-z0-9äöüß]+", "_", t, flags=re.IGNORECASE)
|
||||||
|
|
@ -155,9 +166,10 @@ def list_focus_areas(
|
||||||
query = "SELECT * FROM focus_areas"
|
query = "SELECT * FROM focus_areas"
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if status:
|
frag, extra = _sql_active_status("status", status)
|
||||||
query += " WHERE status = %s"
|
if frag:
|
||||||
params.append(status)
|
query += " WHERE" + frag
|
||||||
|
params.extend(extra)
|
||||||
|
|
||||||
query += " ORDER BY sort_order, name"
|
query += " ORDER BY sort_order, name"
|
||||||
|
|
||||||
|
|
@ -275,9 +287,10 @@ def list_training_styles(
|
||||||
"""
|
"""
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
if status:
|
frag, extra = _sql_active_status("ts.status", status)
|
||||||
query += " WHERE ts.status = %s"
|
if frag:
|
||||||
params.append(status)
|
query += " WHERE" + frag
|
||||||
|
params.extend(extra)
|
||||||
|
|
||||||
query += " ORDER BY ts.sort_order, ts.name"
|
query += " ORDER BY ts.sort_order, ts.name"
|
||||||
|
|
||||||
|
|
@ -517,9 +530,10 @@ def list_training_types(
|
||||||
params = []
|
params = []
|
||||||
where = []
|
where = []
|
||||||
|
|
||||||
if status:
|
frag, extra = _sql_active_status("tt.status", status)
|
||||||
where.append("tt.status = %s")
|
if frag:
|
||||||
params.append(status)
|
where.append(frag.strip())
|
||||||
|
params.extend(extra)
|
||||||
|
|
||||||
if focus_area_id is not None:
|
if focus_area_id is not None:
|
||||||
where.append("tt.focus_area_id = %s")
|
where.append("tt.focus_area_id = %s")
|
||||||
|
|
@ -955,9 +969,10 @@ def list_target_groups(
|
||||||
params = []
|
params = []
|
||||||
where = []
|
where = []
|
||||||
|
|
||||||
if status:
|
frag, extra = _sql_active_status("status", status)
|
||||||
where.append("status = %s")
|
if frag:
|
||||||
params.append(status)
|
where.append(frag.strip())
|
||||||
|
params.extend(extra)
|
||||||
|
|
||||||
if where:
|
if where:
|
||||||
query += " WHERE " + " AND ".join(where)
|
query += " WHERE " + " AND ".join(where)
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,14 @@ def list_skills(
|
||||||
params.append(category)
|
params.append(category)
|
||||||
|
|
||||||
if status:
|
if status:
|
||||||
|
if status == "active":
|
||||||
|
where.append("(status = 'active' OR status IS NULL)")
|
||||||
|
else:
|
||||||
where.append("status = %s")
|
where.append("status = %s")
|
||||||
params.append(status)
|
params.append(status)
|
||||||
else:
|
else:
|
||||||
# Default: only active skills
|
# Default: nur aktive (NULL = Legacy, wie Kataloglisten)
|
||||||
where.append("status = 'active'")
|
where.append("(status = 'active' OR status IS NULL)")
|
||||||
|
|
||||||
if where:
|
if where:
|
||||||
query += " WHERE " + " AND ".join(where)
|
query += " WHERE " + " AND ".join(where)
|
||||||
|
|
|
||||||
|
|
@ -2258,8 +2258,10 @@ a.analysis-split__nav-item {
|
||||||
outline: none;
|
outline: none;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
max-height: 50vh;
|
min-height: 120px;
|
||||||
|
max-height: min(70vh, 520px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
resize: vertical;
|
||||||
}
|
}
|
||||||
.rich-text-editor:empty:before {
|
.rich-text-editor:empty:before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
|
|
|
||||||
|
|
@ -2,76 +2,103 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
|
|
||||||
function exec(cmd, value = null) {
|
function exec(cmd, value = null) {
|
||||||
try {
|
try {
|
||||||
document.execCommand(cmd, false, value)
|
return document.execCommand(cmd, false, value)
|
||||||
} catch (_) {
|
} 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.
|
* Leichter WYSIWYG (contenteditable). Wert kommt von außen zuverlässig ins DOM (Edit-Modus).
|
||||||
* Speichert HTML; Anzeige über sanitizeTrainerHtml + dangerouslySetInnerHTML.
|
|
||||||
*/
|
*/
|
||||||
export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px' }) {
|
export default function RichTextEditor({ value, onChange, placeholder, minHeight = '140px' }) {
|
||||||
const ref = useRef(null)
|
const ref = useRef(null)
|
||||||
const [focused, setFocused] = useState(false)
|
const [focused, setFocused] = useState(false)
|
||||||
const lastExternal = useRef(value)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return
|
const el = ref.current
|
||||||
if (focused) return
|
if (!el || focused) return
|
||||||
if (value !== lastExternal.current) {
|
const next = value ?? ''
|
||||||
lastExternal.current = value
|
if (el.innerHTML !== next) {
|
||||||
if (ref.current.innerHTML !== (value || '')) {
|
el.innerHTML = next
|
||||||
ref.current.innerHTML = value || ''
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [value, focused])
|
}, [value, focused])
|
||||||
|
|
||||||
const sync = useCallback(() => {
|
const sync = useCallback(() => {
|
||||||
if (!ref.current) return
|
if (!ref.current) return
|
||||||
const html = ref.current.innerHTML
|
onChange(ref.current.innerHTML)
|
||||||
lastExternal.current = html
|
|
||||||
onChange(html)
|
|
||||||
}, [onChange])
|
}, [onChange])
|
||||||
|
|
||||||
const onLink = () => {
|
const run = (fn) => (e) => {
|
||||||
const url = window.prompt('Link-URL (https://…)')
|
e.preventDefault()
|
||||||
if (url) exec('createLink', url)
|
ref.current?.focus()
|
||||||
|
fn()
|
||||||
sync()
|
sync()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onLink = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
ref.current?.focus()
|
||||||
|
const url = window.prompt('Link-URL (https://…)')
|
||||||
|
if (url) {
|
||||||
|
exec('createLink', url)
|
||||||
|
sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rich-text-editor-wrap">
|
<div className="rich-text-editor-wrap">
|
||||||
<div className="rich-text-toolbar" role="toolbar" aria-label="Formatierung">
|
<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>
|
<strong>B</strong>
|
||||||
</button>
|
</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>
|
<em>I</em>
|
||||||
</button>
|
</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
|
U
|
||||||
</button>
|
</button>
|
||||||
<span className="rte-sep" />
|
<span className="rte-sep" />
|
||||||
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('formatBlock', 'h3')}>
|
<button type="button" className="rte-btn" title="Normaler Absatz" onMouseDown={run(normalText)}>
|
||||||
H3
|
Normal
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="rte-btn" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('insertUnorderedList')}>
|
<button type="button" className="rte-btn" title="Zwischenüberschrift" onMouseDown={run(() => formatBlock('h3'))}>
|
||||||
•
|
Ü3
|
||||||
</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>
|
</button>
|
||||||
<span className="rte-sep" />
|
<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
|
Link
|
||||||
</button>
|
</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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,6 @@ function TagRow({ exercise }) {
|
||||||
;(exercise.target_groups || []).forEach((g) => {
|
;(exercise.target_groups || []).forEach((g) => {
|
||||||
tags.push({ key: `tg-${g.id}`, label: g.name, accent: !!g.is_primary })
|
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
|
if (tags.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div className="exercise-tag-row">
|
<div className="exercise-tag-row">
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@ const LEVEL_OPTIONS = [
|
||||||
{ value: 'experte', label: 'Experte' },
|
{ value: 'experte', label: 'Experte' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const AGE_GROUP_OPTIONS = ['Minis', 'Kinder', 'Schüler', 'Teenager', 'Erwachsene']
|
|
||||||
|
|
||||||
function emptyForm() {
|
function emptyForm() {
|
||||||
return {
|
return {
|
||||||
title: '',
|
title: '',
|
||||||
|
|
@ -34,7 +32,6 @@ function emptyForm() {
|
||||||
duration_max: '',
|
duration_max: '',
|
||||||
group_size_min: '',
|
group_size_min: '',
|
||||||
group_size_max: '',
|
group_size_max: '',
|
||||||
age_groups: [],
|
|
||||||
focus_areas_multi: [],
|
focus_areas_multi: [],
|
||||||
training_styles_multi: [],
|
training_styles_multi: [],
|
||||||
training_types_multi: [],
|
training_types_multi: [],
|
||||||
|
|
@ -58,7 +55,6 @@ function detailToForm(exercise) {
|
||||||
duration_max: exercise.duration_max ?? '',
|
duration_max: exercise.duration_max ?? '',
|
||||||
group_size_min: exercise.group_size_min ?? '',
|
group_size_min: exercise.group_size_min ?? '',
|
||||||
group_size_max: exercise.group_size_max ?? '',
|
group_size_max: exercise.group_size_max ?? '',
|
||||||
age_groups: exercise.age_groups || [],
|
|
||||||
focus_areas_multi: (exercise.focus_areas || []).map((f) => ({
|
focus_areas_multi: (exercise.focus_areas || []).map((f) => ({
|
||||||
focus_area_id: f.focus_area_id,
|
focus_area_id: f.focus_area_id,
|
||||||
is_primary: !!f.is_primary,
|
is_primary: !!f.is_primary,
|
||||||
|
|
@ -195,7 +191,13 @@ function ExerciseFormPage() {
|
||||||
setTrainingTypes(ttData)
|
setTrainingTypes(ttData)
|
||||||
setTargetGroups(tgData)
|
setTargetGroups(tgData)
|
||||||
} catch (e) {
|
} 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()
|
boot()
|
||||||
|
|
@ -238,13 +240,6 @@ function ExerciseFormPage() {
|
||||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
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 addSkillRow = () => {
|
||||||
const id = skillPick ? parseInt(skillPick, 10) : null
|
const id = skillPick ? parseInt(skillPick, 10) : null
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
|
@ -545,22 +540,6 @@ function ExerciseFormPage() {
|
||||||
</div>
|
</div>
|
||||||
</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
|
<MultiAssocBlock
|
||||||
title="Fokusbereiche (0…n, ein „primär“)"
|
title="Fokusbereiche (0…n, ein „primär“)"
|
||||||
rows={formData.focus_areas_multi}
|
rows={formData.focus_areas_multi}
|
||||||
|
|
@ -812,7 +791,12 @@ function ExerciseFormPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,7 @@ export function buildExerciseApiPayload(formData) {
|
||||||
training_styles_multi: mapStyles,
|
training_styles_multi: mapStyles,
|
||||||
training_types_multi: mapTTypes,
|
training_types_multi: mapTTypes,
|
||||||
target_groups_multi: mapTg,
|
target_groups_multi: mapTg,
|
||||||
age_groups: formData.age_groups || [],
|
age_groups: [],
|
||||||
skills: (formData.skills || []).map((s) => ({
|
skills: (formData.skills || []).map((s) => ({
|
||||||
skill_id: s.skill_id,
|
skill_id: s.skill_id,
|
||||||
is_primary: !!s.is_primary,
|
is_primary: !!s.is_primary,
|
||||||
|
|
@ -340,6 +340,21 @@ export async function deleteExercise(id) {
|
||||||
return request(`/api/exercises/${id}`, { method: 'DELETE' })
|
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)
|
// Catalogs (Admin-verwaltbare Stammdaten)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -785,6 +800,8 @@ export const api = {
|
||||||
updateExercise,
|
updateExercise,
|
||||||
deleteExercise,
|
deleteExercise,
|
||||||
buildExerciseApiPayload,
|
buildExerciseApiPayload,
|
||||||
|
suggestExerciseAi,
|
||||||
|
regenerateExerciseAi,
|
||||||
uploadExerciseMedia,
|
uploadExerciseMedia,
|
||||||
updateExerciseMedia,
|
updateExerciseMedia,
|
||||||
deleteExerciseMedia,
|
deleteExerciseMedia,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user