Increment application version to 0.8.166 and update changelog for new features in AI exercise creation
All checks were successful
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s

- Updated APP_VERSION to 0.8.166 and modified BUILD_DATE to reflect recent changes.
- Enhanced AI exercise creation process with a new quick create feature, allowing users to generate exercises based on search input.
- Introduced a rich text editor for editing AI-generated drafts, improving user experience in exercise creation.
- Updated ExercisePickerModal and related components to support the new quick create functionality, including error handling and input validation.
- Added new utility functions for parsing search queries and building exercise payloads from drafts.
This commit is contained in:
Lars 2026-05-22 19:24:36 +02:00
parent 675cfa85f0
commit 294740b780
8 changed files with 592 additions and 397 deletions

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.165" APP_VERSION = "0.8.166"
BUILD_DATE = "2026-05-31" BUILD_DATE = "2026-05-22"
DB_SCHEMA_VERSION = "20260531071" DB_SCHEMA_VERSION = "20260531071"
MODULE_VERSIONS = { MODULE_VERSIONS = {
@ -27,7 +27,7 @@ MODULE_VERSIONS = {
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder "skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions "skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.32.0", # KI Anleitung: exercise_instruction_rewrite, include_instructions API + UI "exercises": "2.33.0", # KI Schnellanlage: Suche+Anlage kombiniert; Rich-Text-Editor; Übungsliste KI-Schalter
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint "training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung "planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
@ -42,6 +42,14 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.166",
"date": "2026-05-22",
"changes": [
"KI Schnellanlage: Suche und Anlage kombiniert (Picker + Übungsliste); Suchstring → Titel/Skizze; Rich-Text-Entwurf bearbeitbar vor Speichern.",
"Übungsliste: Schalter „KI-Anlage“ für direkten Einstieg ohne leere Trefferliste.",
],
},
{ {
"version": "0.8.165", "version": "0.8.165",
"date": "2026-05-31", "date": "2026-05-31",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-31 **Stand:** 2026-05-31
**App-Version / DB-Schema:** App **`0.8.163`** (KI Anleitung `exercise_instruction_rewrite`); DB **`20260531071`** — maßgeblich **`backend/version.py`**. **App-Version / DB-Schema:** App **`0.8.166`** (KI Schnellanlage Suche+Anlage); DB **`20260531071`** — maßgeblich **`backend/version.py`**.
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -89,7 +89,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`) - **Varianten:** Speichern in der **Aktionsleiste** persistiert zuerst geänderte Varianten (`persistPendingVariantChanges`), dann Übungs-Stammdaten; „Variante anlegen“ als `type="button"` ohne verschachteltes Formular (`createVariantFromDraft`)
- **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions - **Governance (Übungen):** Owner = `created_by`; Bearbeiten = Ersteller, Plattform-Admin oder `can_plan_in_club` bei `visibility=club`; Löschen `club` = nur `club_admin`; Details **`FEATURES_DELIVERED_2026-Q2.md`** §16, **`EXERCISES_API_SPEC.md`** Permissions
### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.163**) ### 2.8 KI Assistenz Übungen & Skill-Katalog-Retrieval (Stand **0.8.166**)
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0P4. - **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0P4.
- **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2 - **Doku:** Umsetzung `.claude/docs/working/AI_EXERCISE_IMPLEMENTATION_PLAN.md`; Profil-/JSON-Konzept `.claude/docs/working/AI_SKILL_RETRIEVAL_PROFILES_SPEC.md`; Ist-Prompt/UI **`AI_PROMPT_SYSTEM_SPEC.md`**; API-Felder **`KI_FEATURES_SPEC.md`** §5.2
@ -99,7 +99,8 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **API:** `POST /api/exercises/ai/suggest`**`include_instructions`**, Body **`preparation`**, **`trainer_notes`**; Response **`instructions.fields`**; **`POST …/ai/regenerate`** mit **`instructions`** in `regenerate` - **API:** `POST /api/exercises/ai/suggest`**`include_instructions`**, Body **`preparation`**, **`trainer_notes`**; Response **`instructions.fields`**; **`POST …/ai/regenerate`** mit **`instructions`** in `regenerate`
- **Pflege:** Superadmin **`/admin/ai-prompts`**, **`/admin/ai-skill-retrieval`** - **Pflege:** Superadmin **`/admin/ai-prompts`**, **`/admin/ai-skill-retrieval`**
- **Diagnose:** **`SHINKAN_AI_DEBUG=1`** — Logs `shinkan.exercise_ai`, `shinkan.openrouter` - **Diagnose:** **`SHINKAN_AI_DEBUG=1`** — Logs `shinkan.exercise_ai`, `shinkan.openrouter`
- **Frontend:** Tab **Anleitung****„KI: Anleitung überarbeiten“**; Vorschau-Dialog pro Feld; Kurzfassung/Fähigkeiten unverändert (**`ExerciseFormPageRoot.jsx`**) - **Frontend Formular:** Tab **Anleitung****„KI: Anleitung überarbeiten“**; Vorschau-Dialog pro Feld (**`ExerciseFormPageRoot.jsx`**)
- **Frontend Schnellanlage:** **`ExercisePickerModal`** (Planung/Rahmen) — Volltextsuche; bei keinem Treffer **„Mit KI anlegen“** (Suchstring → Titel/Skizze); Entwurf im **Rich-Text-Dialog** bearbeiten, dann speichern & übernehmen. **`ExercisesListPageRoot`** — gleiches Muster + Schalter **„KI-Anlage“** in der Suchleiste.
--- ---

View File

@ -0,0 +1,118 @@
import React from 'react'
/**
* Inline-Angebot: aus Suchstring neue Übung per KI anlegen (Fokusbereich + optional Titel/Skizze).
*/
export default function ExerciseAiQuickCreateOffer({
searchLabel,
title,
onTitleChange,
sketch,
onSketchChange,
focusAreaId,
onFocusAreaChange,
focusAreas = [],
catalogsReady = true,
busy = false,
error = '',
onRunAi,
showSketchField = false,
hint,
}) {
const canRun =
!busy &&
(title || '').trim().length >= 3 &&
(sketch || '').trim().length > 0 &&
focusAreaId
return (
<div
className="card"
style={{
padding: '14px 16px',
marginBottom: '12px',
borderColor: 'var(--accent-dark, rgba(29,158,117,0.35))',
background: 'var(--surface2)',
}}
>
<strong style={{ display: 'block', marginBottom: '6px', fontSize: '0.95rem' }}>
Keine passende Übung gefunden
</strong>
<p style={{ margin: '0 0 12px', fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
{hint ||
(searchLabel
? `Für „${searchLabel}“ lässt sich eine neue Übung mit KI vorschlagen — Texte danach bearbeiten und speichern.`
: 'Neue Übung mit KI vorschlagen — Texte danach bearbeiten und speichern.')}
</p>
<div style={{ display: 'grid', gap: '10px' }}>
<div>
<label className="form-label" htmlFor="ex-ai-quick-title">
Titel *
</label>
<input
id="ex-ai-quick-title"
type="text"
className="form-input"
value={title}
onChange={(e) => onTitleChange(e.target.value)}
autoComplete="off"
maxLength={300}
placeholder="Titel der neuen Übung"
/>
</div>
<div>
<label className="form-label" htmlFor="ex-ai-quick-focus">
Fokusbereich *
</label>
<select
id="ex-ai-quick-focus"
className="form-input"
value={focusAreaId}
onChange={(e) => onFocusAreaChange(e.target.value)}
disabled={!catalogsReady}
>
<option value=""> wählen </option>
{(focusAreas || []).map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{`${fa.icon ? `${fa.icon} ` : ''}${fa.name || `#${fa.id}`}`.trim()}
</option>
))}
</select>
</div>
{showSketchField ? (
<div>
<label className="form-label" htmlFor="ex-ai-quick-sketch">
Skizze / Idee *
</label>
<textarea
id="ex-ai-quick-sketch"
className="form-input"
rows={3}
value={sketch}
onChange={(e) => onSketchChange(e.target.value)}
placeholder="Kurze Beschreibung für die KI …"
/>
</div>
) : (
<p style={{ margin: 0, fontSize: '12px', color: 'var(--text3)' }}>
Ausgangstext: {(sketch || '').trim().slice(0, 160)}
{(sketch || '').trim().length > 160 ? '…' : ''}
</p>
)}
{error ? (
<p style={{ margin: 0, fontSize: '13px', color: 'var(--danger)' }}>{error}</p>
) : null}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<button type="button" className="btn btn-primary" disabled={!canRun} onClick={() => onRunAi()}>
{busy ? 'KI erzeugt Vorschlag…' : 'Mit KI anlegen'}
</button>
</div>
</div>
</div>
)
}

View File

@ -1,35 +1,29 @@
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { describeAiSkillRowForPreview } from '../utils/exerciseAiQuickCreate' import RichTextEditor from './RichTextEditor'
import {
const summaryBoxSx = { INSTRUCTION_AI_FIELD_DEFS,
padding: '10px 12px', describeAiSkillRowForPreview,
borderRadius: '8px', } from '../utils/exerciseAiQuickCreate'
border: '1px solid var(--border)', import { stripHtmlToText } from '../utils/htmlUtils'
background: 'var(--surface2)',
fontSize: '13px',
lineHeight: 1.45,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
minHeight: '72px',
}
/** /**
* Modal: KI-Vorschlag prüfen (Formular + Schnellanlage). * Modal: KI-Entwurf bearbeiten (Rich-Text) und speichern.
*/ */
export default function ExerciseAiSuggestPreviewModal({ export default function ExerciseAiSuggestPreviewModal({
preview, draft,
onPreviewChange, onDraftChange,
onDiscard, onDiscard,
onApply, onApply,
focusAreas = [],
skillsCatalog = [], skillsCatalog = [],
dialogTitle = 'KI-Vorschlag prüfen', dialogTitle = 'Neue Übung — KI-Entwurf bearbeiten',
hint = 'Vergleichen und nur die gewünschten Teile übernehmen.', hint = 'Texte formatiert anzeigen und bei Bedarf anpassen, dann speichern.',
applyLabel = 'Ausgewähltes übernehmen', applyLabel = 'Übung anlegen',
applyDisabled = false, applyDisabled = false,
zIndex = 2000, zIndex = 2000,
}) { }) {
useEffect(() => { useEffect(() => {
if (!preview) return undefined if (!draft) return undefined
const onKey = (e) => { const onKey = (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault()
@ -39,16 +33,29 @@ export default function ExerciseAiSuggestPreviewModal({
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey)
}, [preview, onDiscard]) }, [draft, onDiscard])
if (!preview) return null if (!draft) return null
const p = preview const fields = draft.instructionFields || {}
const canApplySomething = const canApply =
!applyDisabled && !applyDisabled &&
((p.applySummary && p.summaryAfterHtml) || (draft.title || '').trim().length >= 3 &&
(p.skillChoices || []).some((c) => c.include) || draft.focusAreaId &&
(p.instructionChoices || []).some((c) => c.include && c.afterHtml)) (stripHtmlToText(fields.goal).trim() || stripHtmlToText(fields.execution).trim())
const patchDraft = (patch) => onDraftChange((prev) => (prev ? { ...prev, ...patch } : prev))
const patchInstructionField = (key, html) => {
onDraftChange((prev) =>
prev
? {
...prev,
instructionFields: { ...(prev.instructionFields || {}), [key]: html },
}
: prev,
)
}
return ( return (
<div <div
@ -68,9 +75,9 @@ export default function ExerciseAiSuggestPreviewModal({
<div <div
className="card" className="card"
style={{ style={{
maxWidth: 760, maxWidth: 820,
margin: '3vh auto', margin: '2vh auto',
maxHeight: '92vh', maxHeight: '94vh',
overflow: 'auto', overflow: 'auto',
position: 'relative', position: 'relative',
}} }}
@ -79,148 +86,79 @@ export default function ExerciseAiSuggestPreviewModal({
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>{dialogTitle}</h3> <h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>{dialogTitle}</h3>
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>{hint}</p> <p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>{hint}</p>
{p.hasInstructionChoices ? ( <div style={{ display: 'grid', gap: '12px', marginBottom: '18px' }}>
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-instructions-heading"> <div>
<div <label className="form-label" htmlFor="ai-draft-title">
id="ai-preview-instructions-heading" Titel *
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
>
Anleitung ({p.instructionChoices.length}{' '}
{p.instructionChoices.length === 1 ? 'Feld' : 'Felder'})
</div>
{p.instructionChoices.map((c) => (
<div
key={c.key}
style={{
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '12px',
marginBottom: '12px',
background: 'var(--surface)',
}}
>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '10px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 600,
}}
>
<input
type="checkbox"
checked={c.include}
onChange={(e) =>
onPreviewChange((prev) =>
prev
? {
...prev,
instructionChoices: prev.instructionChoices.map((x) =>
x.key === c.key ? { ...x, include: e.target.checked } : x,
),
}
: prev,
)
}
/>
{c.label} übernehmen
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
gap: '12px',
}}
>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
Ausgang (Plaintext)
</div>
<div style={summaryBoxSx}>{c.beforePlain || '(leer)'}</div>
</div>
<div>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>KI-Vorschlag</div>
<div
style={{
...summaryBoxSx,
borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))',
}}
>
{c.afterPlain || '(leer)'}
</div>
</div>
</div>
</div>
))}
</section>
) : null}
{p.hasSummaryProposal ? (
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
<div
id="ai-preview-summary-heading"
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
>
Kurzfassung
</div>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '10px',
cursor: 'pointer',
fontSize: '14px',
}}
>
<input
type="checkbox"
checked={p.applySummary}
onChange={(e) =>
onPreviewChange((prev) => (prev ? { ...prev, applySummary: e.target.checked } : prev))
}
/>
Kurzfassung übernehmen
</label> </label>
<div <input
style={{ id="ai-draft-title"
display: 'grid', type="text"
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)', className="form-input"
gap: '12px', value={draft.title || ''}
}} onChange={(e) => patchDraft({ title: e.target.value })}
maxLength={300}
/>
</div>
<div>
<label className="form-label" htmlFor="ai-draft-focus">
Fokusbereich *
</label>
<select
id="ai-draft-focus"
className="form-input"
value={draft.focusAreaId || ''}
onChange={(e) => patchDraft({ focusAreaId: e.target.value })}
> >
<div> <option value=""> wählen </option>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>Ausgang</div> {(focusAreas || []).map((fa) => (
<div style={summaryBoxSx}>{p.summaryBeforePlain || '(leer)'}</div> <option key={fa.id} value={String(fa.id)}>
</div> {`${fa.icon ? `${fa.icon} ` : ''}${fa.name || `#${fa.id}`}`.trim()}
<div> </option>
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>KI-Vorschlag</div> ))}
<div </select>
style={{ </div>
...summaryBoxSx, </div>
borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))',
}}
>
{p.summaryAfterPlain || '(leer)'}
</div>
</div>
</div>
</section>
) : null}
{p.skillsRequested !== false && p.hasSkillChoices ? ( <section style={{ marginBottom: '18px' }} aria-labelledby="ai-draft-summary-heading">
<section aria-labelledby="ai-preview-skills-heading"> <div id="ai-draft-summary-heading" style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '8px' }}>
<div Kurzfassung
id="ai-preview-skills-heading" </div>
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }} <RichTextEditor
> value={draft.summaryHtml || ''}
Fähigkeiten ({p.skillChoices.length}) onChange={(html) => patchDraft({ summaryHtml: html })}
placeholder="Kurzbeschreibung …"
minHeight="88px"
/>
</section>
<section style={{ marginBottom: '18px' }} aria-labelledby="ai-draft-instructions-heading">
<div
id="ai-draft-instructions-heading"
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
>
Anleitung
</div>
{INSTRUCTION_AI_FIELD_DEFS.map((def) => (
<div key={def.key} style={{ marginBottom: '14px' }}>
<label className="form-label">{def.label}</label>
<RichTextEditor
value={fields[def.key] || ''}
onChange={(html) => patchInstructionField(def.key, html)}
placeholder={`${def.label}`}
minHeight={def.key === 'execution' ? '140px' : '100px'}
/>
</div>
))}
</section>
{(draft.skillChoices || []).length > 0 ? (
<section aria-labelledby="ai-draft-skills-heading">
<div id="ai-draft-skills-heading" style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}>
Fähigkeiten ({draft.skillChoices.length})
</div> </div>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}> <ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{p.skillChoices.map((c) => ( {draft.skillChoices.map((c) => (
<li <li
key={c.key} key={c.key}
style={{ style={{
@ -242,10 +180,10 @@ export default function ExerciseAiSuggestPreviewModal({
> >
<input <input
type="checkbox" type="checkbox"
checked={c.include} checked={!!c.include}
style={{ marginTop: 3 }} style={{ marginTop: 3 }}
onChange={(e) => onChange={(e) =>
onPreviewChange((prev) => onDraftChange((prev) =>
prev prev
? { ? {
...prev, ...prev,
@ -279,12 +217,7 @@ export default function ExerciseAiSuggestPreviewModal({
<button type="button" className="btn btn-secondary" onClick={() => onDiscard()}> <button type="button" className="btn btn-secondary" onClick={() => onDiscard()}>
Abbrechen Abbrechen
</button> </button>
<button <button type="button" className="btn btn-primary" disabled={!canApply} onClick={() => onApply()}>
type="button"
className="btn btn-primary"
disabled={!canApplySomething}
onClick={() => onApply()}
>
{applyLabel} {applyLabel}
</button> </button>
</div> </div>

View File

@ -18,9 +18,12 @@ import SkillTreeMultiSelect from './SkillTreeMultiSelect'
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker' import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
import CatalogRulePicker from './CatalogRulePicker' import CatalogRulePicker from './CatalogRulePicker'
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
import ExerciseAiQuickCreateOffer from './ExerciseAiQuickCreateOffer'
import { import {
buildQuickCreateAiPreview, buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromPreview, buildQuickCreateExercisePayloadFromDraft,
aiPreviewToQuickCreateDraft,
parseSearchQueryForQuickCreate,
} from '../utils/exerciseAiQuickCreate' } from '../utils/exerciseAiQuickCreate'
const PAGE_SIZE = 100 const PAGE_SIZE = 100
@ -58,16 +61,12 @@ export default function ExercisePickerModal({
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
const [multiPicked, setMultiPicked] = useState([]) const [multiPicked, setMultiPicked] = useState([])
const [quickOpen, setQuickOpen] = useState(false)
const [quickTitle, setQuickTitle] = useState('') const [quickTitle, setQuickTitle] = useState('')
const [quickSketch, setQuickSketch] = useState('') const [quickSketch, setQuickSketch] = useState('')
const [quickFocusAreaId, setQuickFocusAreaId] = useState('') const [quickFocusAreaId, setQuickFocusAreaId] = useState('')
const [quickSaving, setQuickSaving] = useState(false) const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('') const [quickAiError, setQuickAiError] = useState('')
const [quickCreatePreview, setQuickCreatePreview] = useState(null) const [quickCreateDraft, setQuickCreateDraft] = useState(null)
const [quickLibraryHits, setQuickLibraryHits] = useState([])
const [quickLibraryLoading, setQuickLibraryLoading] = useState(false)
const [debouncedQuickLibraryQ, setDebouncedQuickLibraryQ] = useState('')
const pickerScrollRef = useRef(null) const pickerScrollRef = useRef(null)
const toggleMultiPick = (ex) => { const toggleMultiPick = (ex) => {
@ -86,47 +85,24 @@ export default function ExercisePickerModal({
return () => clearTimeout(t) return () => clearTimeout(t)
}, [aiSearchInput]) }, [aiSearchInput])
const quickLibraryQuery = useMemo(() => { const parsedQuickCreate = useMemo(
const t = (quickTitle || '').trim() () => parseSearchQueryForQuickCreate(debouncedSearch),
const s = (quickSketch || '').trim() [debouncedSearch],
return [t, s].filter(Boolean).join(' ').trim() )
}, [quickTitle, quickSketch])
useEffect(() => { useEffect(() => {
const t = setTimeout(() => setDebouncedQuickLibraryQ(quickLibraryQuery), 400) if (!enableQuickCreateDraft || !debouncedSearch) return
return () => clearTimeout(t) setQuickTitle(parsedQuickCreate.title)
}, [quickLibraryQuery]) setQuickSketch(parsedQuickCreate.sketch || debouncedSearch)
setQuickAiError('')
}, [enableQuickCreateDraft, debouncedSearch, parsedQuickCreate.title, parsedQuickCreate.sketch])
useEffect(() => { const showQuickCreateOffer =
if (!open || !quickOpen || !catalogsReady) { enableQuickCreateDraft &&
setQuickLibraryHits([]) catalogsReady &&
return !loading &&
} debouncedSearch.length >= 3 &&
if (debouncedQuickLibraryQ.length < 3) { list.length === 0
setQuickLibraryHits([])
return
}
let cancelled = false
;(async () => {
setQuickLibraryLoading(true)
try {
const q = { search: debouncedQuickLibraryQ, include_archived: true, limit: 8, offset: 0 }
if (Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0) {
q.exercise_kind_any = exerciseKindAny
}
const batch = await api.listExercises(q)
if (!cancelled) setQuickLibraryHits(Array.isArray(batch) ? batch : [])
} catch (e) {
console.error(e)
if (!cancelled) setQuickLibraryHits([])
} finally {
if (!cancelled) setQuickLibraryLoading(false)
}
})()
return () => {
cancelled = true
}
}, [open, quickOpen, catalogsReady, debouncedQuickLibraryQ, exerciseKindAny])
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@ -171,15 +147,12 @@ export default function ExercisePickerModal({
setList([]) setList([])
setHasMore(false) setHasMore(false)
setMultiPicked([]) setMultiPicked([])
setQuickOpen(false)
setQuickTitle('') setQuickTitle('')
setQuickSketch('') setQuickSketch('')
setQuickFocusAreaId('') setQuickFocusAreaId('')
setQuickSaving(false) setQuickSaving(false)
setQuickAiError('') setQuickAiError('')
setQuickCreatePreview(null) setQuickCreateDraft(null)
setQuickLibraryHits([])
setDebouncedQuickLibraryQ('')
return return
} }
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs)) setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
@ -357,7 +330,7 @@ export default function ExercisePickerModal({
} }
const sketch = (quickSketch || '').trim() const sketch = (quickSketch || '').trim()
if (!sketch) { if (!sketch) {
alert('Bitte eine kurze Skizze / Idee eingeben.') alert('Bitte einen Suchbegriff oder eine Skizze eingeben.')
return return
} }
const focusId = parseInt(String(quickFocusAreaId).trim(), 10) const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
@ -370,7 +343,7 @@ export default function ExercisePickerModal({
const focusHint = (focusRow?.name || '').trim() const focusHint = (focusRow?.name || '').trim()
setQuickAiError('') setQuickAiError('')
setQuickCreatePreview(null) setQuickCreateDraft(null)
setQuickSaving(true) setQuickSaving(true)
try { try {
const aiRes = await api.suggestExerciseAi({ const aiRes = await api.suggestExerciseAi({
@ -390,7 +363,9 @@ export default function ExercisePickerModal({
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) { if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.') throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
} }
setQuickCreatePreview(preview) setQuickCreateDraft(
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
const msg = e?.message || String(e) const msg = e?.message || String(e)
@ -401,22 +376,16 @@ export default function ExercisePickerModal({
} }
} }
const applyQuickCreatePreview = async () => { const applyQuickCreateDraft = async () => {
const title = (quickTitle || '').trim() if (!quickCreateDraft) return
const sketch = (quickSketch || '').trim()
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
if (!quickCreatePreview) return
setQuickSaving(true) setQuickSaving(true)
setQuickAiError('') setQuickAiError('')
try { try {
const payload = buildQuickCreateExercisePayloadFromPreview(quickCreatePreview, { const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
title,
focusAreaId: focusId,
sketchPlain: sketch,
})
const created = await api.createExercise(payload) const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen') if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
setQuickCreateDraft(null)
await adoptExistingExercise(created) await adoptExistingExercise(created)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
@ -454,150 +423,6 @@ export default function ExercisePickerModal({
</button> </button>
</div> </div>
{enableQuickCreateDraft ? (
<div
style={{
padding: '10px 1rem 12px',
borderBottom: '1px solid var(--border)',
flexShrink: 0,
background: 'var(--surface2)',
}}
>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
onClick={() => setQuickOpen((v) => !v)}
aria-expanded={quickOpen}
>
{quickOpen ? 'Neue Übung ausblenden' : 'Bibliothek prüfen / neu anlegen'}
</button>
{quickOpen ? (
<div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}>
<p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
Zuerst prüft die Suche die <strong>Bibliothek</strong> (Titel + Skizze). Passt eine Übung, direkt
übernehmen. Sonst erzeugt die KI einen Vorschlag Anleitung, Kurzbeschreibung, Fähigkeiten den du
im Dialog prüfst, bevor gespeichert wird.
</p>
<div>
<label className="form-label" htmlFor="ex-picker-quick-title">
Titel *
</label>
<input
id="ex-picker-quick-title"
type="text"
className="form-input"
value={quickTitle}
onChange={(e) => setQuickTitle(e.target.value)}
autoComplete="off"
minLength={3}
maxLength={300}
placeholder="z.B. Partnerübung Abwehr"
/>
</div>
<div>
<label className="form-label" htmlFor="ex-picker-quick-focus">
Fokusbereich *
</label>
<select
id="ex-picker-quick-focus"
className="form-input"
value={quickFocusAreaId}
onChange={(e) => setQuickFocusAreaId(e.target.value)}
disabled={!catalogsReady}
>
<option value=""> wählen </option>
{(catalogs.focusAreas || []).map((fa) => (
<option key={fa.id} value={String(fa.id)}>
{`${fa.icon ? `${fa.icon} ` : ''}${fa.name || `#${fa.id}`}`.trim()}
</option>
))}
</select>
</div>
<div>
<label className="form-label" htmlFor="ex-picker-quick-sketch">
Skizze / Idee *
</label>
<textarea
id="ex-picker-quick-sketch"
className="form-input"
rows={4}
value={quickSketch}
onChange={(e) => setQuickSketch(e.target.value)}
placeholder="Kurze Beschreibung: Ablauf, Material, Ziel der Übung …"
/>
</div>
<div
style={{
borderTop: '1px solid var(--border)',
paddingTop: '12px',
marginTop: '4px',
}}
>
<strong style={{ fontSize: '13px' }}>Passende Übungen in der Bibliothek</strong>
{quickLibraryQuery.length < 3 ? (
<p style={{ margin: '8px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
Titel oder Skizze (mind. 3 Zeichen) dann erscheinen Treffer.
</p>
) : quickLibraryLoading ? (
<p style={{ margin: '8px 0 0', fontSize: '12px', color: 'var(--text3)' }}>Suche läuft</p>
) : quickLibraryHits.length === 0 ? (
<p style={{ margin: '8px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
Keine Treffer unten KI-Vorschlag erzeugen.
</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: '10px 0 0' }}>
{quickLibraryHits.map((ex) => (
<li key={ex.id} style={{ marginBottom: '8px' }}>
<button
type="button"
className="btn btn-secondary"
style={{
width: '100%',
textAlign: 'left',
padding: '10px 12px',
display: 'block',
}}
onClick={() => adoptExistingExercise(ex)}
>
<strong style={{ display: 'block' }}>{ex.title}</strong>
{(ex.summary || '').trim() ? (
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
{(ex.summary || '').trim().slice(0, 100)}
{(ex.summary || '').trim().length > 100 ? '…' : ''}
</span>
) : null}
</button>
</li>
))}
</ul>
)}
</div>
{quickAiError ? (
<p style={{ margin: 0, fontSize: '13px', color: 'var(--danger)' }}>{quickAiError}</p>
) : null}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', justifyContent: 'flex-end' }}>
<button
type="button"
className="btn btn-primary"
disabled={
quickSaving ||
(quickTitle || '').trim().length < 3 ||
!(quickSketch || '').trim() ||
!quickFocusAreaId
}
onClick={runQuickCreateAiSuggest}
>
{quickSaving ? 'KI erzeugt Vorschlag…' : 'KI-Vorschlag erzeugen'}
</button>
</div>
</div>
) : null}
</div>
) : null}
<div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}> <div style={{ padding: '0 1rem 0.75rem', borderBottom: '1px solid var(--border)', flexShrink: 0 }}>
<div style={{ display: 'grid', gap: '0.65rem' }}> <div style={{ display: 'grid', gap: '0.65rem' }}>
<div> <div>
@ -778,7 +603,28 @@ export default function ExercisePickerModal({
<div className="spinner" /> <div className="spinner" />
</div> </div>
) : list.length === 0 ? ( ) : list.length === 0 ? (
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>Keine Treffer.</p> showQuickCreateOffer ? (
<ExerciseAiQuickCreateOffer
searchLabel={debouncedSearch}
title={quickTitle}
onTitleChange={setQuickTitle}
sketch={quickSketch}
onSketchChange={setQuickSketch}
focusAreaId={quickFocusAreaId}
onFocusAreaChange={setQuickFocusAreaId}
focusAreas={catalogs.focusAreas}
catalogsReady={catalogsReady}
busy={quickSaving}
error={quickAiError}
onRunAi={runQuickCreateAiSuggest}
/>
) : (
<p style={{ color: 'var(--text2)', textAlign: 'center' }}>
{debouncedSearch.length >= 3
? 'Keine Treffer.'
: 'Suchbegriff eingeben (mind. 3 Zeichen) …'}
</p>
)
) : ( ) : (
<> <>
<p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}> <p style={{ fontSize: '13px', color: 'var(--text2)', marginBottom: 10 }}>
@ -948,13 +794,14 @@ export default function ExercisePickerModal({
</div> </div>
<ExerciseAiSuggestPreviewModal <ExerciseAiSuggestPreviewModal
preview={quickCreatePreview} draft={quickCreateDraft}
onPreviewChange={setQuickCreatePreview} onDraftChange={setQuickCreateDraft}
onDiscard={() => setQuickCreatePreview(null)} onDiscard={() => setQuickCreateDraft(null)}
onApply={applyQuickCreatePreview} onApply={applyQuickCreateDraft}
focusAreas={catalogs.focusAreas}
skillsCatalog={catalogs.skills} skillsCatalog={catalogs.skills}
dialogTitle="Neue Übung — KI-Vorschlag prüfen" dialogTitle="Neue Übung — KI-Entwurf bearbeiten"
hint="Felder und Fähigkeiten per Checkbox wählen. Erst „Anlegen und übernehmen“ speichert die Übung (Entwurf, privat) und übernimmt sie in den Ablauf." hint="Texte sind formatiert — passe Titel, Kurzfassung, Anleitung und Fähigkeiten an, dann speichern und übernehmen."
applyLabel={quickSaving ? 'Wird angelegt…' : 'Anlegen und übernehmen'} applyLabel={quickSaving ? 'Wird angelegt…' : 'Anlegen und übernehmen'}
applyDisabled={quickSaving} applyDisabled={quickSaving}
zIndex={2100} zIndex={2100}

View File

@ -14,6 +14,8 @@ export default function ExerciseListSearchBar({
exerciseCount, exerciseCount,
allOnPageSelected, allOnPageSelected,
onToggleSelectAllPage, onToggleSelectAllPage,
aiQuickCreateEnabled = false,
onToggleAiQuickCreate,
}) { }) {
return ( return (
<div className="card exercise-search-bar"> <div className="card exercise-search-bar">
@ -48,6 +50,19 @@ export default function ExerciseListSearchBar({
/> />
<div className="exercise-search-bar__actions exercise-search-bar__actions--split"> <div className="exercise-search-bar__actions exercise-search-bar__actions--split">
<div className="exercise-search-bar__actions-main"> <div className="exercise-search-bar__actions-main">
{typeof onToggleAiQuickCreate === 'function' ? (
<button
type="button"
className={
'btn btn-secondary exercise-mine-toggle' +
(aiQuickCreateEnabled ? ' exercise-mine-toggle--active' : '')
}
onClick={onToggleAiQuickCreate}
title="Neue Übung per KI aus dem Suchtext vorschlagen und anlegen"
>
KI-Anlage
</button>
) : null}
<button <button
type="button" type="button"
className={'btn btn-secondary exercise-mine-toggle' + (mineOnly ? ' exercise-mine-toggle--active' : '')} className={'btn btn-secondary exercise-mine-toggle' + (mineOnly ? ' exercise-mine-toggle--active' : '')}
@ -93,6 +108,13 @@ export default function ExerciseListSearchBar({
<p className="exercise-search-hint"> <p className="exercise-search-hint">
Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im
Feld). Fachliche Filter über Filter zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER. Feld). Fachliche Filter über Filter zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
{aiQuickCreateEnabled ? (
<>
{' '}
<strong>KI-Anlage aktiv:</strong> Suchtext als Ausgang für einen KI-Entwurf ohne Treffer oder direkt über
Mit KI anlegen.
</>
) : null}
{exerciseCount > 0 ? ( {exerciseCount > 0 ? (
<> <>
{' '} {' '}

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react' import React, { useState, useEffect, useMemo, useCallback, useRef, lazy, Suspense } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../../utils/api' import api from '../../utils/api'
import { useAuth } from '../../context/AuthContext' import { useAuth } from '../../context/AuthContext'
import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub' import { activeClubMemberships, getTenantClubDependencyKey } from '../../utils/activeClub'
@ -11,6 +12,14 @@ import ExerciseListBulkToolbar from './ExerciseListBulkToolbar'
import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal' import SaveSelectedExercisesAsModuleModal from './SaveSelectedExercisesAsModuleModal'
import ExercisePeekModal from '../ExercisePeekModal' import ExercisePeekModal from '../ExercisePeekModal'
import NavStateLink from '../NavStateLink' import NavStateLink from '../NavStateLink'
import ExerciseAiQuickCreateOffer from '../ExerciseAiQuickCreateOffer'
import ExerciseAiSuggestPreviewModal from '../ExerciseAiSuggestPreviewModal'
import {
buildQuickCreateAiPreview,
buildQuickCreateExercisePayloadFromDraft,
aiPreviewToQuickCreateDraft,
parseSearchQueryForQuickCreate,
} from '../../utils/exerciseAiQuickCreate'
import { buildExercisesListReturnContext } from '../../utils/navReturnContext' import { buildExercisesListReturnContext } from '../../utils/navReturnContext'
import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips' import { buildExerciseListFilterChips } from '../../utils/exerciseListFilterChips'
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree' import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
@ -37,6 +46,7 @@ const EXERCISES_PAGE_TABS = [
] ]
function ExercisesListPageRoot() { function ExercisesListPageRoot() {
const navigate = useNavigate()
const { user, checkAuth } = useAuth() const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin' const isSuperadmin = user?.role === 'superadmin'
@ -78,6 +88,13 @@ function ExercisesListPageRoot() {
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([]) const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
const [peekExercise, setPeekExercise] = useState(null) const [peekExercise, setPeekExercise] = useState(null)
const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false) const [saveModuleModalOpen, setSaveModuleModalOpen] = useState(false)
const [aiQuickCreateEnabled, setAiQuickCreateEnabled] = useState(false)
const [quickTitle, setQuickTitle] = useState('')
const [quickSketch, setQuickSketch] = useState('')
const [quickFocusAreaId, setQuickFocusAreaId] = useState('')
const [quickSaving, setQuickSaving] = useState(false)
const [quickAiError, setQuickAiError] = useState('')
const [quickCreateDraft, setQuickCreateDraft] = useState(null)
useEffect(() => { useEffect(() => {
if (!user?.id) return if (!user?.id) return
@ -126,6 +143,18 @@ function ExercisesListPageRoot() {
return () => clearTimeout(t) return () => clearTimeout(t)
}, [aiSearchInput]) }, [aiSearchInput])
const parsedQuickCreate = useMemo(
() => parseSearchQueryForQuickCreate(debouncedSearch),
[debouncedSearch],
)
useEffect(() => {
if (!debouncedSearch) return
setQuickTitle(parsedQuickCreate.title)
setQuickSketch(parsedQuickCreate.sketch || debouncedSearch)
setQuickAiError('')
}, [debouncedSearch, parsedQuickCreate.title, parsedQuickCreate.sketch])
useEffect(() => { useEffect(() => {
if (!filterModalOpen) return if (!filterModalOpen) return
const onKey = (e) => { const onKey = (e) => {
@ -151,6 +180,13 @@ function ExercisesListPageRoot() {
loadMore, loadMore,
} = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey }) } = useExerciseListCatalogsAndQuery({ queryBase, pageTab, tenantClubDepKey })
const showQuickCreateOffer =
pageTab === 'list' &&
catalogsReady &&
!listFetching &&
(aiQuickCreateEnabled ||
(exercises.length === 0 && selectedEntries.length === 0 && debouncedSearch.length >= 3))
const selectedIds = useMemo( const selectedIds = useMemo(
() => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)), () => new Set(selectedEntries.map((e) => Number(e.id)).filter((id) => Number.isFinite(id) && id > 0)),
[selectedEntries] [selectedEntries]
@ -304,6 +340,85 @@ function ExercisesListPageRoot() {
const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), []) const exercisesModuleReturnContext = useMemo(() => buildExercisesListReturnContext(), [])
const runQuickCreateAiSuggest = useCallback(async () => {
const title = (quickTitle || '').trim()
if (title.length < 3) {
alert('Titel: mindestens 3 Zeichen.')
return
}
const sketch = (quickSketch || '').trim()
if (!sketch) {
alert('Bitte Suchtext oder Skizze eingeben.')
return
}
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
if (!Number.isFinite(focusId) || focusId < 1) {
alert('Bitte einen Fokusbereich wählen.')
return
}
const focusRow = (catalogs.focusAreas || []).find((x) => Number(x.id) === focusId)
const focusHint = (focusRow?.name || '').trim()
setQuickAiError('')
setQuickCreateDraft(null)
setQuickSaving(true)
try {
const aiRes = await api.suggestExerciseAi({
title,
goal: sketch,
execution: '',
preparation: '',
trainer_notes: '',
focus_area_hint: focusHint || undefined,
focus_areas_context: [{ focus_area_id: focusId, is_primary: true }],
include_summary: true,
include_skills: true,
include_instructions: true,
})
const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch })
if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) {
throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.')
}
setQuickCreateDraft(
aiPreviewToQuickCreateDraft(preview, { title, focusAreaId: focusId, sketchPlain: sketch }),
)
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'KI-Vorschlag fehlgeschlagen')
} finally {
setQuickSaving(false)
}
}, [quickTitle, quickSketch, quickFocusAreaId, catalogs.focusAreas])
const applyQuickCreateDraft = useCallback(async () => {
if (!quickCreateDraft) return
setQuickSaving(true)
setQuickAiError('')
try {
const payload = buildQuickCreateExercisePayloadFromDraft(quickCreateDraft)
const created = await api.createExercise(payload)
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
setQuickCreateDraft(null)
setAiQuickCreateEnabled(false)
setExercises((prev) => [created, ...prev])
navigate(`/exercises/${created.id}/edit`, {
state: { returnContext: exercisesModuleReturnContext },
})
} catch (e) {
console.error(e)
const msg = e?.message || String(e)
setQuickAiError(msg)
alert(msg || 'Übung konnte nicht angelegt werden')
} finally {
setQuickSaving(false)
}
}, [quickCreateDraft, setExercises, navigate, exercisesModuleReturnContext])
const bulkVisibilityOptions = useMemo(() => { const bulkVisibilityOptions = useMemo(() => {
const base = [ const base = [
{ id: '', label: '— nicht ändern —' }, { id: '', label: '— nicht ändern —' },
@ -535,8 +650,33 @@ function ExercisesListPageRoot() {
exerciseCount={exercises.length} exerciseCount={exercises.length}
allOnPageSelected={allOnPageSelected} allOnPageSelected={allOnPageSelected}
onToggleSelectAllPage={toggleSelectAllPage} onToggleSelectAllPage={toggleSelectAllPage}
aiQuickCreateEnabled={aiQuickCreateEnabled}
onToggleAiQuickCreate={() => setAiQuickCreateEnabled((v) => !v)}
/> />
{showQuickCreateOffer ? (
<ExerciseAiQuickCreateOffer
searchLabel={debouncedSearch || undefined}
title={quickTitle}
onTitleChange={setQuickTitle}
sketch={quickSketch}
onSketchChange={setQuickSketch}
focusAreaId={quickFocusAreaId}
onFocusAreaChange={setQuickFocusAreaId}
focusAreas={catalogs.focusAreas}
catalogsReady={catalogsReady}
busy={quickSaving}
error={quickAiError}
onRunAi={runQuickCreateAiSuggest}
showSketchField={aiQuickCreateEnabled}
hint={
aiQuickCreateEnabled
? 'KI-Anlage: Suchtext oder eigene Skizze als Ausgang — Fokusbereich wählen, dann KI-Vorschlag erzeugen und bearbeiten.'
: undefined
}
/>
) : null}
<ExerciseListBulkToolbar <ExerciseListBulkToolbar
selectedCount={selectedIds.size} selectedCount={selectedIds.size}
bulkMaxIds={BULK_MAX_IDS} bulkMaxIds={BULK_MAX_IDS}
@ -625,11 +765,15 @@ function ExercisesListPageRoot() {
Lade Übungen Lade Übungen
</p> </p>
</div> </div>
) : exercises.length === 0 && selectedEntries.length === 0 ? ( ) : exercises.length === 0 && selectedEntries.length === 0 && !showQuickCreateOffer ? (
<div className="card"> <div className="card">
<p className="exercises-empty-text">Keine Übungen gefunden.</p> <p className="exercises-empty-text">
{debouncedSearch.length >= 3
? 'Keine Übungen gefunden.'
: 'Keine Übungen gefunden — Suchbegriff eingeben oder Filter anpassen.'}
</p>
</div> </div>
) : ( ) : exercises.length === 0 && selectedEntries.length === 0 && showQuickCreateOffer ? null : (
<> <>
{selectedEntries.length > 0 ? ( {selectedEntries.length > 0 ? (
<section className="exercises-selection-section" data-testid="exercises-selection-section"> <section className="exercises-selection-section" data-testid="exercises-selection-section">
@ -696,6 +840,19 @@ function ExercisesListPageRoot() {
)} )}
</> </>
)} )}
<ExerciseAiSuggestPreviewModal
draft={quickCreateDraft}
onDraftChange={setQuickCreateDraft}
onDiscard={() => setQuickCreateDraft(null)}
onApply={applyQuickCreateDraft}
focusAreas={catalogs.focusAreas}
skillsCatalog={catalogs.skills}
dialogTitle="Neue Übung — KI-Entwurf bearbeiten"
hint="Texte sind formatiert — passe sie an und lege die Übung als Entwurf an."
applyLabel={quickSaving ? 'Wird angelegt…' : 'Übung anlegen'}
applyDisabled={quickSaving}
/>
</> </>
)} )}
</div> </div>

View File

@ -24,6 +24,38 @@ function escapeHtmlText(s) {
.replace(/"/g, '&quot;') .replace(/"/g, '&quot;')
} }
/** Suchstring → Titel + Skizze für Schnellanlage (erste Zeile / Satz als Titel). */
export function parseSearchQueryForQuickCreate(searchText) {
const raw = String(searchText || '').trim()
if (!raw) return { title: '', sketch: '' }
const lines = raw.split(/\n/).map((l) => l.trim()).filter(Boolean)
if (lines.length >= 2) {
return {
title: lines[0].slice(0, 300),
sketch: lines.slice(1).join('\n'),
}
}
const single = lines[0] || raw
if (single.length <= 80) {
return { title: single.slice(0, 300), sketch: single }
}
const sentenceMatch = single.match(/^(.{10,120}?[.!?;:])\s+(.+)$/s)
if (sentenceMatch) {
return {
title: sentenceMatch[1].trim().slice(0, 300),
sketch: single,
}
}
const words = single.split(/\s+/).filter(Boolean)
const titleWordCount = Math.min(8, Math.max(3, Math.ceil(words.length / 3)))
const title = words.slice(0, titleWordCount).join(' ').slice(0, 120)
return { title, sketch: single }
}
/** Plaintext → Absätze für RichTextEditor / API. */ /** Plaintext → Absätze für RichTextEditor / API. */
export function aiPlainTextToMinimalHtml(text) { export function aiPlainTextToMinimalHtml(text) {
const raw = String(text || '').trim() const raw = String(text || '').trim()
@ -133,8 +165,85 @@ export function describeAiSkillRowForPreview(row, skillsCatalog) {
return `${name}: Intensität ${int}, Niveau ${from}${to}${prim}` return `${name}: Intensität ${int}, Niveau ${from}${to}${prim}`
} }
/** KI-Vorschau → bearbeitbarer Entwurf (Rich-Text-Felder). */
export function aiPreviewToQuickCreateDraft(preview, { title, focusAreaId, sketchPlain }) {
const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain)
const instructionFields = {
goal: sketchHtml,
execution: '',
preparation: '',
trainer_notes: '',
}
for (const c of preview?.instructionChoices || []) {
if (c.field && c.include !== false && c.afterHtml) {
instructionFields[c.field] = String(c.afterHtml)
}
}
return {
title: (title || '').trim(),
focusAreaId: focusAreaId != null && focusAreaId !== '' ? String(focusAreaId) : '',
summaryHtml:
preview?.applySummary !== false && preview?.summaryAfterHtml ? String(preview.summaryAfterHtml) : '',
instructionFields,
skillChoices: (preview?.skillChoices || []).map((c) => ({ ...c })),
}
}
/** /**
* createExercise-Payload aus bestätigter Vorschau. * createExercise-Payload aus bearbeitetem Entwurf.
* @throws {Error}
*/
export function buildQuickCreateExercisePayloadFromDraft(draft) {
const title = (draft?.title || '').trim()
if (title.length < 3) {
throw new Error('Titel: mindestens 3 Zeichen.')
}
const fid = Number(draft?.focusAreaId)
if (!Number.isFinite(fid) || fid < 1) {
throw new Error('Fokusbereich ist erforderlich.')
}
const fields = draft?.instructionFields || {}
let goal = (fields.goal || '').trim()
let execution = (fields.execution || '').trim()
const prep = (fields.preparation || '').trim() || null
const trainerNotes = (fields.trainer_notes || '').trim() || null
if (!stripHtmlToText(goal).trim() && !stripHtmlToText(execution).trim()) {
throw new Error('Mindestens Ziel oder Durchführung ausfüllen.')
}
if (!stripHtmlToText(goal).trim()) goal = execution
if (!stripHtmlToText(execution).trim()) execution = goal
let summary = (draft?.summaryHtml || '').trim() || null
if (summary && !stripHtmlToText(summary).trim()) summary = null
const skills = (draft?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
return {
title,
summary,
goal,
execution,
preparation: prep,
trainer_notes: trainerNotes,
visibility: 'private',
status: 'draft',
equipment: [],
focus_areas_multi: [{ focus_area_id: fid, is_primary: true }],
training_styles_multi: [],
training_types_multi: [],
target_groups_multi: [],
age_groups: [],
skills,
club_id: null,
}
}
/**
* createExercise-Payload aus bestätigter Vorschau (Checkbox-Modus).
* @throws {Error} * @throws {Error}
*/ */
export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) { export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) {