KI Übungen - MVP 0.8 #48
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 P0–P4.
|
- **Zielarchitektur (Pflicht fuer Erweiterungen):** `.claude/docs/technical/AI_PROMPT_TARGET_ARCHITECTURE.md` — Kontext-Arten, Composition, Einbindung Planung/Rahmen; Phasenplan P0–P4.
|
||||||
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
118
frontend/src/components/ExerciseAiQuickCreateOffer.jsx
Normal file
118
frontend/src/components/ExerciseAiQuickCreateOffer.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
<label className="form-label" htmlFor="ai-draft-title">
|
||||||
|
Titel *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="ai-draft-title"
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
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 })}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: '18px' }} aria-labelledby="ai-draft-summary-heading">
|
||||||
|
<div id="ai-draft-summary-heading" style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '8px' }}>
|
||||||
|
Kurzfassung
|
||||||
|
</div>
|
||||||
|
<RichTextEditor
|
||||||
|
value={draft.summaryHtml || ''}
|
||||||
|
onChange={(html) => patchDraft({ summaryHtml: html })}
|
||||||
|
placeholder="Kurzbeschreibung …"
|
||||||
|
minHeight="88px"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section style={{ marginBottom: '18px' }} aria-labelledby="ai-draft-instructions-heading">
|
||||||
<div
|
<div
|
||||||
id="ai-preview-instructions-heading"
|
id="ai-draft-instructions-heading"
|
||||||
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
|
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
|
||||||
>
|
>
|
||||||
Anleitung ({p.instructionChoices.length}{' '}
|
Anleitung
|
||||||
{p.instructionChoices.length === 1 ? 'Feld' : 'Felder'})
|
|
||||||
</div>
|
</div>
|
||||||
{p.instructionChoices.map((c) => (
|
{INSTRUCTION_AI_FIELD_DEFS.map((def) => (
|
||||||
<div
|
<div key={def.key} style={{ marginBottom: '14px' }}>
|
||||||
key={c.key}
|
<label className="form-label">{def.label}</label>
|
||||||
style={{
|
<RichTextEditor
|
||||||
border: '1px solid var(--border)',
|
value={fields[def.key] || ''}
|
||||||
borderRadius: '8px',
|
onChange={(html) => patchInstructionField(def.key, html)}
|
||||||
padding: '12px',
|
placeholder={`${def.label} …`}
|
||||||
marginBottom: '12px',
|
minHeight={def.key === 'execution' ? '140px' : '100px'}
|
||||||
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{p.hasSummaryProposal ? (
|
{(draft.skillChoices || []).length > 0 ? (
|
||||||
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
|
<section aria-labelledby="ai-draft-skills-heading">
|
||||||
<div
|
<div id="ai-draft-skills-heading" style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}>
|
||||||
id="ai-preview-summary-heading"
|
Fähigkeiten ({draft.skillChoices.length})
|
||||||
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>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'grid',
|
|
||||||
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
|
|
||||||
gap: '12px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>Ausgang</div>
|
|
||||||
<div style={summaryBoxSx}>{p.summaryBeforePlain || '(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))',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{p.summaryAfterPlain || '(leer)'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{p.skillsRequested !== false && p.hasSkillChoices ? (
|
|
||||||
<section aria-labelledby="ai-preview-skills-heading">
|
|
||||||
<div
|
|
||||||
id="ai-preview-skills-heading"
|
|
||||||
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
|
|
||||||
>
|
|
||||||
Fähigkeiten ({p.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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 ? (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,38 @@ function escapeHtmlText(s) {
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 }) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user