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"])
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user