Enhance Planning Exercise Suggestion with Client Context and Group ID Support
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m16s
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m16s
- Made `unit_id` and `group_id` optional in `PlanningExerciseSuggestRequest` to support client context without a saved unit. - Refactored `_load_group_recent_exercise_ids` to handle cases where `exclude_unit_id` is optional. - Introduced `build_client_planning_context_pack` for improved context handling in client-free searches. - Updated `suggest_planning_exercises` to utilize the new client context pack when `unit_id` is not provided. - Incremented version to 0.8.172 and updated changelog to reflect these enhancements in the planning AI capabilities.
This commit is contained in:
parent
f5c886fc13
commit
614c2dcfaa
|
|
@ -48,7 +48,8 @@ _LLM_RERANK_PRE_LIMIT = 32
|
||||||
|
|
||||||
|
|
||||||
class PlanningExerciseSuggestRequest(BaseModel):
|
class PlanningExerciseSuggestRequest(BaseModel):
|
||||||
unit_id: int = Field(..., ge=1)
|
unit_id: Optional[int] = Field(default=None, ge=1)
|
||||||
|
group_id: Optional[int] = Field(default=None, ge=1)
|
||||||
section_order_index: Optional[int] = Field(default=None, ge=0)
|
section_order_index: Optional[int] = Field(default=None, ge=0)
|
||||||
phase_order_index: Optional[int] = Field(default=None, ge=0)
|
phase_order_index: Optional[int] = Field(default=None, ge=0)
|
||||||
parallel_stream_order_index: Optional[int] = Field(default=None, ge=0)
|
parallel_stream_order_index: Optional[int] = Field(default=None, ge=0)
|
||||||
|
|
@ -196,26 +197,42 @@ def _load_progression_successors(
|
||||||
def _load_group_recent_exercise_ids(
|
def _load_group_recent_exercise_ids(
|
||||||
cur,
|
cur,
|
||||||
group_id: Optional[int],
|
group_id: Optional[int],
|
||||||
exclude_unit_id: int,
|
exclude_unit_id: Optional[int] = None,
|
||||||
limit: int = 40,
|
limit: int = 40,
|
||||||
) -> Set[int]:
|
) -> Set[int]:
|
||||||
if not group_id:
|
if not group_id:
|
||||||
return set()
|
return set()
|
||||||
cur.execute(
|
if exclude_unit_id is not None:
|
||||||
"""
|
cur.execute(
|
||||||
SELECT tusi.exercise_id AS eid
|
"""
|
||||||
FROM training_units tu
|
SELECT tusi.exercise_id AS eid
|
||||||
INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
|
FROM training_units tu
|
||||||
INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
|
INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
|
||||||
WHERE tu.group_id = %s
|
INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
|
||||||
AND tu.id <> %s
|
WHERE tu.group_id = %s
|
||||||
AND tusi.exercise_id IS NOT NULL
|
AND tu.id <> %s
|
||||||
AND COALESCE(tu.status, '') <> 'cancelled'
|
AND tusi.exercise_id IS NOT NULL
|
||||||
ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC
|
AND COALESCE(tu.status, '') <> 'cancelled'
|
||||||
LIMIT 200
|
ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC
|
||||||
""",
|
LIMIT 200
|
||||||
(int(group_id), int(exclude_unit_id)),
|
""",
|
||||||
)
|
(int(group_id), int(exclude_unit_id)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT tusi.exercise_id AS eid
|
||||||
|
FROM training_units tu
|
||||||
|
INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
|
||||||
|
INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
|
||||||
|
WHERE tu.group_id = %s
|
||||||
|
AND tusi.exercise_id IS NOT NULL
|
||||||
|
AND COALESCE(tu.status, '') <> 'cancelled'
|
||||||
|
ORDER BY tu.planned_date DESC NULLS LAST, tu.id DESC, tusi.order_index DESC
|
||||||
|
LIMIT 200
|
||||||
|
""",
|
||||||
|
(int(group_id),),
|
||||||
|
)
|
||||||
out: Set[int] = set()
|
out: Set[int] = set()
|
||||||
for r in cur.fetchall():
|
for r in cur.fetchall():
|
||||||
if r.get("eid") is None:
|
if r.get("eid") is None:
|
||||||
|
|
@ -364,13 +381,82 @@ def build_planning_exercise_context_pack(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_client_planning_context_pack(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
tenant: TenantContext,
|
||||||
|
body: PlanningExerciseSuggestRequest,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Freie / Client-Kontext-Suche ohne persistierte training_units.id (Formular, Rahmen-Slot)."""
|
||||||
|
role = tenant.global_role
|
||||||
|
if not _has_planning_role(role):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Planungs-Vorschläge abrufen")
|
||||||
|
|
||||||
|
planned_ids: List[int] = []
|
||||||
|
if body.planned_exercise_ids:
|
||||||
|
seen: Set[int] = set()
|
||||||
|
for raw in body.planned_exercise_ids:
|
||||||
|
try:
|
||||||
|
eid = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if eid < 1 or eid in seen:
|
||||||
|
continue
|
||||||
|
seen.add(eid)
|
||||||
|
planned_ids.append(eid)
|
||||||
|
|
||||||
|
anchor_id = _resolve_anchor_from_plan(planned_ids, body.anchor_exercise_id)
|
||||||
|
anchor_skills = _load_skill_ids_for_exercise(cur, anchor_id)
|
||||||
|
progression_ids, progression_notes = _load_progression_successors(
|
||||||
|
cur, body.progression_graph_id, anchor_id
|
||||||
|
)
|
||||||
|
|
||||||
|
group_id = body.group_id
|
||||||
|
group_name = None
|
||||||
|
if group_id:
|
||||||
|
cur.execute("SELECT name FROM training_groups WHERE id = %s", (int(group_id),))
|
||||||
|
gr = cur.fetchone()
|
||||||
|
if gr:
|
||||||
|
group_name = (gr.get("name") or "").strip() or None
|
||||||
|
|
||||||
|
group_recent = _load_group_recent_exercise_ids(cur, group_id, exclude_unit_id=None)
|
||||||
|
titles = _load_exercise_titles(cur, [x for x in [anchor_id] if x])
|
||||||
|
anchor_title = titles.get(anchor_id) if anchor_id else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"unit_id": None,
|
||||||
|
"unit": {
|
||||||
|
"id": None,
|
||||||
|
"framework_slot_id": None,
|
||||||
|
"origin_framework_slot_id": None,
|
||||||
|
},
|
||||||
|
"unit_title": None,
|
||||||
|
"group_id": group_id,
|
||||||
|
"group_name": group_name,
|
||||||
|
"section_order_index": body.section_order_index,
|
||||||
|
"section_title": None,
|
||||||
|
"planned_exercise_ids": planned_ids,
|
||||||
|
"anchor_exercise_id": anchor_id,
|
||||||
|
"anchor_title": anchor_title,
|
||||||
|
"anchor_skill_ids": sorted(anchor_skills),
|
||||||
|
"progression_graph_id": body.progression_graph_id,
|
||||||
|
"progression_successor_ids": sorted(progression_ids),
|
||||||
|
"progression_edge_notes": progression_notes,
|
||||||
|
"group_recent_exercise_ids": sorted(group_recent),
|
||||||
|
"context_mode": "client_free",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def suggest_planning_exercises(
|
def suggest_planning_exercises(
|
||||||
cur,
|
cur,
|
||||||
*,
|
*,
|
||||||
tenant: TenantContext,
|
tenant: TenantContext,
|
||||||
body: PlanningExerciseSuggestRequest,
|
body: PlanningExerciseSuggestRequest,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body)
|
if body.unit_id:
|
||||||
|
pack = build_planning_exercise_context_pack(cur, tenant=tenant, body=body)
|
||||||
|
else:
|
||||||
|
pack = build_client_planning_context_pack(cur, tenant=tenant, body=body)
|
||||||
pack = _apply_client_planned_override(cur, pack, body)
|
pack = _apply_client_planned_override(cur, pack, body)
|
||||||
query = _normalize_query(body.query)
|
query = _normalize_query(body.query)
|
||||||
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
heuristic_intent = resolve_planning_exercise_intent(query, body.intent_hint)
|
||||||
|
|
@ -601,6 +687,7 @@ def suggest_planning_exercises(
|
||||||
"anchor_title": pack.get("anchor_title"),
|
"anchor_title": pack.get("anchor_title"),
|
||||||
"anchor_exercise_id": pack.get("anchor_exercise_id"),
|
"anchor_exercise_id": pack.get("anchor_exercise_id"),
|
||||||
"progression_graph_id": pack.get("progression_graph_id"),
|
"progression_graph_id": pack.get("progression_graph_id"),
|
||||||
|
"context_mode": pack.get("context_mode") or ("unit" if pack.get("unit_id") else "client_free"),
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.171"
|
APP_VERSION = "0.8.172"
|
||||||
BUILD_DATE = "2026-05-22"
|
BUILD_DATE = "2026-05-22"
|
||||||
DB_SCHEMA_VERSION = "20260531073"
|
DB_SCHEMA_VERSION = "20260531073"
|
||||||
|
|
||||||
|
|
@ -28,7 +28,7 @@ MODULE_VERSIONS = {
|
||||||
"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.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
"exercises": "2.37.0", # Planungs-KI P1: Szenario-Pipeline + Query-Intent-Overlay
|
||||||
"planning_exercise_suggest": "0.4.0", # include_llm_intent, scenario_kind, query_intent_summary
|
"planning_exercise_suggest": "0.4.1", # unit_id optional; client_free Kontext; group_id
|
||||||
"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
|
||||||
|
|
@ -43,6 +43,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.172",
|
||||||
|
"date": "2026-05-22",
|
||||||
|
"changes": [
|
||||||
|
"Planungs-KI UI: Menüpunkt „Planungs-KI: Übung vorschlagen“ im +-Dialog; Freitext-Suche ohne gespeicherte unit_id (client_free).",
|
||||||
|
"API exercise-suggest: unit_id optional, group_id für Client-Kontext.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.171",
|
"version": "0.8.171",
|
||||||
"date": "2026-05-22",
|
"date": "2026-05-22",
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,11 @@ export default function ExercisePickerModal({
|
||||||
enableQuickCreateDraft = false,
|
enableQuickCreateDraft = false,
|
||||||
/** Gespeicherte training_units.id — aktiviert Planungs-KI (robuster als nur planningContext). */
|
/** Gespeicherte training_units.id — aktiviert Planungs-KI (robuster als nur planningContext). */
|
||||||
planningUnitId = null,
|
planningUnitId = null,
|
||||||
/** true auf TrainingUnitEditPage: Hinweis wenn Einheit noch keine ID hat. */
|
/** 'planning' = Planungs-KI-API; 'library' = Volltext-Bibliothek */
|
||||||
|
pickerMode = 'library',
|
||||||
|
/** Planungs-KI auch ohne gespeicherte unit_id (Client-Kontext / Freitext). */
|
||||||
|
enableFreePlanningSearch = false,
|
||||||
|
/** true auf TrainingUnitEditPage: Hinweis wenn Planungs-KI ohne Einheit und ohne Freitext. */
|
||||||
expectPlanningSearch = false,
|
expectPlanningSearch = false,
|
||||||
/** Planungs-Kontext für KI-Suche (Abschnitt, Anker, Plan …) */
|
/** Planungs-Kontext für KI-Suche (Abschnitt, Anker, Plan …) */
|
||||||
planningContext = null,
|
planningContext = null,
|
||||||
|
|
@ -84,9 +88,11 @@ export default function ExercisePickerModal({
|
||||||
}, [planningUnitId, planningContext?.unitId])
|
}, [planningUnitId, planningContext?.unitId])
|
||||||
|
|
||||||
const activePlanningContext = useMemo(() => {
|
const activePlanningContext = useMemo(() => {
|
||||||
if (!resolvedPlanningUnitId) return null
|
if (pickerMode !== 'planning') return null
|
||||||
return {
|
const groupIdRaw = planningContext?.groupId
|
||||||
unitId: resolvedPlanningUnitId,
|
const groupId = Number(groupIdRaw)
|
||||||
|
const base = {
|
||||||
|
groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null,
|
||||||
sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0,
|
sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0,
|
||||||
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
|
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
|
||||||
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
|
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
|
||||||
|
|
@ -97,10 +103,24 @@ export default function ExercisePickerModal({
|
||||||
: [],
|
: [],
|
||||||
intentHint: planningContext?.intentHint ?? null,
|
intentHint: planningContext?.intentHint ?? null,
|
||||||
}
|
}
|
||||||
}, [resolvedPlanningUnitId, planningContext])
|
if (!resolvedPlanningUnitId) {
|
||||||
|
if (!enableFreePlanningSearch && !planningContext) return null
|
||||||
|
return { unitId: null, ...base }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
unitId: resolvedPlanningUnitId,
|
||||||
|
...base,
|
||||||
|
}
|
||||||
|
}, [pickerMode, resolvedPlanningUnitId, enableFreePlanningSearch, planningContext])
|
||||||
|
|
||||||
const usePlanningSearch = resolvedPlanningUnitId != null
|
const usePlanningSearch = pickerMode === 'planning' && activePlanningContext != null
|
||||||
const planningSearchBlocked = Boolean(expectPlanningSearch && !usePlanningSearch)
|
const useFreePlanningSearch = usePlanningSearch && !resolvedPlanningUnitId
|
||||||
|
const planningSearchBlocked = Boolean(
|
||||||
|
pickerMode === 'planning' &&
|
||||||
|
expectPlanningSearch &&
|
||||||
|
!resolvedPlanningUnitId &&
|
||||||
|
!enableFreePlanningSearch
|
||||||
|
)
|
||||||
|
|
||||||
/** Gemeinsamer Suchtext — in Planung nur ein Feld; in Bibliothek beide Felder kombiniert. */
|
/** Gemeinsamer Suchtext — in Planung nur ein Feld; in Bibliothek beide Felder kombiniert. */
|
||||||
const effectivePickerQuery = useMemo(() => {
|
const effectivePickerQuery = useMemo(() => {
|
||||||
|
|
@ -306,8 +326,7 @@ export default function ExercisePickerModal({
|
||||||
try {
|
try {
|
||||||
if (usePlanningSearch && activePlanningContext) {
|
if (usePlanningSearch && activePlanningContext) {
|
||||||
const query = effectivePickerQuery
|
const query = effectivePickerQuery
|
||||||
const res = await api.suggestPlanningExercises({
|
const requestBody = {
|
||||||
unit_id: Number(activePlanningContext.unitId),
|
|
||||||
section_order_index:
|
section_order_index:
|
||||||
activePlanningContext.sectionOrderIndex != null
|
activePlanningContext.sectionOrderIndex != null
|
||||||
? Number(activePlanningContext.sectionOrderIndex)
|
? Number(activePlanningContext.sectionOrderIndex)
|
||||||
|
|
@ -336,13 +355,20 @@ export default function ExercisePickerModal({
|
||||||
.filter((x) => Number.isFinite(x) && x > 0)
|
.filter((x) => Number.isFinite(x) && x > 0)
|
||||||
: undefined,
|
: undefined,
|
||||||
include_llm_intent: Boolean(query),
|
include_llm_intent: Boolean(query),
|
||||||
include_llm_rank: true,
|
include_llm_rank: Boolean(query),
|
||||||
query,
|
query,
|
||||||
intent_hint: activePlanningContext.intentHint || null,
|
intent_hint: activePlanningContext.intentHint || (useFreePlanningSearch && query ? 'free_search' : null),
|
||||||
limit: PAGE_SIZE,
|
limit: PAGE_SIZE,
|
||||||
exercise_kind_any:
|
exercise_kind_any:
|
||||||
Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined,
|
Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0 ? exerciseKindAny : undefined,
|
||||||
})
|
}
|
||||||
|
if (resolvedPlanningUnitId) {
|
||||||
|
requestBody.unit_id = Number(resolvedPlanningUnitId)
|
||||||
|
}
|
||||||
|
if (activePlanningContext.groupId) {
|
||||||
|
requestBody.group_id = Number(activePlanningContext.groupId)
|
||||||
|
}
|
||||||
|
const res = await api.suggestPlanningExercises(requestBody)
|
||||||
setPlanningContextSummary(res?.context_summary || null)
|
setPlanningContextSummary(res?.context_summary || null)
|
||||||
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
|
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
|
||||||
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
|
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
|
||||||
|
|
@ -397,6 +423,8 @@ export default function ExercisePickerModal({
|
||||||
activePlanningContext,
|
activePlanningContext,
|
||||||
effectivePickerQuery,
|
effectivePickerQuery,
|
||||||
exerciseKindAny,
|
exerciseKindAny,
|
||||||
|
resolvedPlanningUnitId,
|
||||||
|
useFreePlanningSearch,
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -643,12 +671,11 @@ export default function ExercisePickerModal({
|
||||||
lineHeight: 1.45,
|
lineHeight: 1.45,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>Planungs-KI noch nicht verfügbar.</strong> Die Einheit hat noch keine gespeicherte ID —
|
<strong>Planungs-KI noch nicht verfügbar.</strong> Bitte zuerst <strong>Speichern</strong> oder den
|
||||||
bitte zuerst <strong>Speichern</strong>, dann den Übungspicker erneut öffnen. Bis dahin gilt nur die
|
Menüpunkt <strong>Planungs-KI: Übung vorschlagen</strong> nutzen (Freitext ohne gespeicherte Einheit).
|
||||||
Bibliothekssuche (Volltext).
|
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
{!usePlanningSearch && !planningSearchBlocked ? (
|
{pickerMode === 'library' && expectPlanningSearch ? (
|
||||||
<p
|
<p
|
||||||
style={{
|
style={{
|
||||||
margin: '0 0 10px',
|
margin: '0 0 10px',
|
||||||
|
|
@ -660,9 +687,40 @@ export default function ExercisePickerModal({
|
||||||
color: 'var(--text2)',
|
color: 'var(--text2)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong style={{ color: 'var(--text1)' }}>Bibliothekssuche (Volltext)</strong> — Planungs-KI mit
|
<strong style={{ color: 'var(--text1)' }}>Bibliothekssuche (Volltext)</strong> — für Planungs-KI mit
|
||||||
Kontext (Einheit, Plan, Anker) gibt es in der{' '}
|
Kontext oder Freitext-Anfrage den Menüpunkt{' '}
|
||||||
<strong>Trainingseinheit bearbeiten</strong>, nach dem Speichern der Einheit.
|
<strong>Planungs-KI: Übung vorschlagen …</strong> unter dem <strong>+</strong> wählen.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{useFreePlanningSearch ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0 0 10px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ color: 'var(--text1)' }}>Freie Planungs-KI</strong> — Anker und bisherige Übungen aus
|
||||||
|
dem Formular; nach Speichern kommen Gruppe, Historie und Rahmen dazu.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{!usePlanningSearch && !planningSearchBlocked && !expectPlanningSearch ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: '0 0 10px',
|
||||||
|
padding: '8px 10px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--text2)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ color: 'var(--text1)' }}>Bibliothekssuche (Volltext)</strong>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,7 @@ export default function TrainingUnitSectionsEditor({
|
||||||
sections,
|
sections,
|
||||||
onSectionsChange,
|
onSectionsChange,
|
||||||
onRequestExercisePick,
|
onRequestExercisePick,
|
||||||
|
onRequestPlanningExercisePick,
|
||||||
onRequestTrainingModulePick,
|
onRequestTrainingModulePick,
|
||||||
onPeekExercise,
|
onPeekExercise,
|
||||||
showExecutionExtras = false,
|
showExecutionExtras = false,
|
||||||
|
|
@ -2591,12 +2592,23 @@ export default function TrainingUnitSectionsEditor({
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
||||||
|
{onRequestPlanningExercisePick ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary framework-ctrl framework-ctrl--xs"
|
||||||
|
onClick={() =>
|
||||||
|
onRequestPlanningExercisePick?.({ sectionIndex: sIdx })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Planungs-KI
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||||
onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })}
|
onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })}
|
||||||
>
|
>
|
||||||
+ Übung
|
+ Übung (Bibliothek)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -2807,9 +2819,25 @@ export default function TrainingUnitSectionsEditor({
|
||||||
ändern.
|
ändern.
|
||||||
</p>
|
</p>
|
||||||
<div className="tu-insert-chooser-actions">
|
<div className="tu-insert-chooser-actions">
|
||||||
|
{onRequestPlanningExercisePick ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary tu-insert-chooser-actions__full"
|
||||||
|
onClick={() => {
|
||||||
|
const { sIdx, beforeIx } = insertChooser
|
||||||
|
closeInsertChooser()
|
||||||
|
onRequestPlanningExercisePick?.({
|
||||||
|
sectionIndex: sIdx,
|
||||||
|
insertBeforeIndex: beforeIx,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Planungs-KI: Übung vorschlagen …
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary tu-insert-chooser-actions__full"
|
className={`btn ${onRequestPlanningExercisePick ? 'btn-secondary' : 'btn-primary'} tu-insert-chooser-actions__full`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const { sIdx, beforeIx } = insertChooser
|
const { sIdx, beforeIx } = insertChooser
|
||||||
closeInsertChooser()
|
closeInsertChooser()
|
||||||
|
|
@ -2819,7 +2847,7 @@ export default function TrainingUnitSectionsEditor({
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Übung auswählen …
|
{onRequestPlanningExercisePick ? 'Übung aus Bibliothek …' : 'Übung auswählen …'}
|
||||||
</button>
|
</button>
|
||||||
{onRequestTrainingModulePick ? (
|
{onRequestTrainingModulePick ? (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export default function TrainingUnitFormShell({
|
||||||
onRequestSaveAsModule,
|
onRequestSaveAsModule,
|
||||||
onRequestTrainingModulePick,
|
onRequestTrainingModulePick,
|
||||||
onRequestExercisePick,
|
onRequestExercisePick,
|
||||||
|
onRequestPlanningExercisePick,
|
||||||
onPeekExercise,
|
onPeekExercise,
|
||||||
formId = 'planning-unit-form',
|
formId = 'planning-unit-form',
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -427,6 +428,7 @@ export default function TrainingUnitFormShell({
|
||||||
}
|
}
|
||||||
onRequestTrainingModulePick={onRequestTrainingModulePick}
|
onRequestTrainingModulePick={onRequestTrainingModulePick}
|
||||||
onRequestExercisePick={onRequestExercisePick}
|
onRequestExercisePick={onRequestExercisePick}
|
||||||
|
onRequestPlanningExercisePick={onRequestPlanningExercisePick}
|
||||||
onPeekExercise={onPeekExercise}
|
onPeekExercise={onPeekExercise}
|
||||||
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
|
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
|
||||||
enableParallelPhaseControls
|
enableParallelPhaseControls
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,76 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([])
|
const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([])
|
||||||
const [sectionPickerCtx, setSectionPickerCtx] = useState(null)
|
const [sectionPickerCtx, setSectionPickerCtx] = useState(null)
|
||||||
const [peekCtx, setPeekCtx] = useState(null)
|
const [peekCtx, setPeekCtx] = useState(null)
|
||||||
|
|
||||||
|
const frameworkPlanningContext = useMemo(() => {
|
||||||
|
if (!sectionPickerCtx) return null
|
||||||
|
const { slotIdx, sectionIndex: sIdx, itemIndex: iIdx, insertBeforeIndex: beforeIx } = sectionPickerCtx
|
||||||
|
const slot = form.slots?.[slotIdx]
|
||||||
|
const secs = slot?.sections?.length ? slot.sections : [defaultSection('Ablauf')]
|
||||||
|
const sec = secs[sIdx]
|
||||||
|
let anchorExerciseId = null
|
||||||
|
if (sec?.items?.length) {
|
||||||
|
if (typeof iIdx === 'number') {
|
||||||
|
const item = sec.items[iIdx]
|
||||||
|
if (item?.exercise_id) {
|
||||||
|
anchorExerciseId = Number(item.exercise_id)
|
||||||
|
} else {
|
||||||
|
for (let i = iIdx - 1; i >= 0; i -= 1) {
|
||||||
|
if (sec.items[i]?.exercise_id) {
|
||||||
|
anchorExerciseId = Number(sec.items[i].exercise_id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (typeof beforeIx === 'number') {
|
||||||
|
for (let i = beforeIx - 1; i >= 0; i -= 1) {
|
||||||
|
if (sec.items[i]?.exercise_id) {
|
||||||
|
anchorExerciseId = Number(sec.items[i].exercise_id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (sec.items[i]?.exercise_id) {
|
||||||
|
anchorExerciseId = Number(sec.items[i].exercise_id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const plannedExerciseIds = []
|
||||||
|
const seenPlan = new Set()
|
||||||
|
for (const s of secs) {
|
||||||
|
for (const it of s?.items || []) {
|
||||||
|
if (String(it?.item_type || '').toLowerCase() === 'note') continue
|
||||||
|
const eid = Number(it?.exercise_id)
|
||||||
|
if (!Number.isFinite(eid) || eid < 1 || seenPlan.has(eid)) continue
|
||||||
|
seenPlan.add(eid)
|
||||||
|
plannedExerciseIds.push(eid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
unitId: null,
|
||||||
|
groupId: null,
|
||||||
|
sectionOrderIndex: sIdx,
|
||||||
|
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
||||||
|
progressionGraphId: null,
|
||||||
|
plannedExerciseIds,
|
||||||
|
}
|
||||||
|
}, [sectionPickerCtx, form.slots])
|
||||||
|
|
||||||
|
const openFrameworkExercisePicker = useCallback((slotIdx, ctx, pickerMode = 'library') => {
|
||||||
|
setSectionPickerCtx({
|
||||||
|
slotIdx,
|
||||||
|
sectionIndex: ctx.sectionIndex,
|
||||||
|
itemIndex: typeof ctx.itemIndex === 'number' ? ctx.itemIndex : undefined,
|
||||||
|
insertBeforeIndex:
|
||||||
|
typeof ctx.insertBeforeIndex === 'number' && Number.isFinite(ctx.insertBeforeIndex)
|
||||||
|
? ctx.insertBeforeIndex
|
||||||
|
: undefined,
|
||||||
|
pickerMode,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
const [editingGoalIdx, setEditingGoalIdx] = useState(null)
|
const [editingGoalIdx, setEditingGoalIdx] = useState(null)
|
||||||
const [goalMenuGi, setGoalMenuGi] = useState(null)
|
const [goalMenuGi, setGoalMenuGi] = useState(null)
|
||||||
/** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */
|
/** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */
|
||||||
|
|
@ -911,17 +981,8 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
}}
|
}}
|
||||||
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) =>
|
onRequestExercisePick={(ctx) => openFrameworkExercisePicker(si, ctx, 'library')}
|
||||||
setSectionPickerCtx({
|
onRequestPlanningExercisePick={(ctx) => openFrameworkExercisePicker(si, ctx, 'planning')}
|
||||||
slotIdx: si,
|
|
||||||
sectionIndex,
|
|
||||||
itemIndex: typeof itemIndex === 'number' ? itemIndex : undefined,
|
|
||||||
insertBeforeIndex:
|
|
||||||
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
|
|
||||||
? insertBeforeIndex
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
onPeekExercise={(id, variantId) =>
|
onPeekExercise={(id, variantId) =>
|
||||||
setPeekCtx({ exerciseId: id, variantId: variantId ?? null })
|
setPeekCtx({ exerciseId: id, variantId: variantId ?? null })
|
||||||
}
|
}
|
||||||
|
|
@ -1366,6 +1427,9 @@ export default function TrainingFrameworkProgramEditPage() {
|
||||||
open={sectionPickerCtx != null}
|
open={sectionPickerCtx != null}
|
||||||
multiSelect
|
multiSelect
|
||||||
enableQuickCreateDraft
|
enableQuickCreateDraft
|
||||||
|
pickerMode={sectionPickerCtx?.pickerMode === 'planning' ? 'planning' : 'library'}
|
||||||
|
enableFreePlanningSearch
|
||||||
|
planningContext={frameworkPlanningContext}
|
||||||
onClose={() => setSectionPickerCtx(null)}
|
onClose={() => setSectionPickerCtx(null)}
|
||||||
onSelectExercises={async (picked) => {
|
onSelectExercises={async (picked) => {
|
||||||
if (!sectionPickerCtx || !picked?.length) return
|
if (!sectionPickerCtx || !picked?.length) return
|
||||||
|
|
|
||||||
|
|
@ -124,9 +124,9 @@ export default function TrainingUnitEditPage() {
|
||||||
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
|
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
|
||||||
|
|
||||||
const exercisePickerPlanningContext = useMemo(() => {
|
const exercisePickerPlanningContext = useMemo(() => {
|
||||||
|
if (!exercisePickerTarget) return null
|
||||||
const resolvedUnitId =
|
const resolvedUnitId =
|
||||||
editingUnit?.id ?? (Number.isFinite(unitId) && unitId > 0 ? unitId : null)
|
editingUnit?.id ?? (Number.isFinite(unitId) && unitId > 0 ? unitId : null)
|
||||||
if (!resolvedUnitId) return null
|
|
||||||
const target = exercisePickerTarget
|
const target = exercisePickerTarget
|
||||||
const secs = formData.sections || []
|
const secs = formData.sections || []
|
||||||
const sIdx = target?.sIdx ?? 0
|
const sIdx = target?.sIdx ?? 0
|
||||||
|
|
@ -145,6 +145,14 @@ export default function TrainingUnitEditPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (typeof target?.insertBeforeIndex === 'number') {
|
||||||
|
const beforeIx = target.insertBeforeIndex
|
||||||
|
for (let i = beforeIx - 1; i >= 0; i -= 1) {
|
||||||
|
if (sec.items[i]?.exercise_id) {
|
||||||
|
anchorExerciseId = Number(sec.items[i].exercise_id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
|
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
|
||||||
if (sec.items[i]?.exercise_id) {
|
if (sec.items[i]?.exercise_id) {
|
||||||
|
|
@ -165,14 +173,29 @@ export default function TrainingUnitEditPage() {
|
||||||
plannedExerciseIds.push(eid)
|
plannedExerciseIds.push(eid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const groupIdRaw = Number(formData.group_id)
|
||||||
return {
|
return {
|
||||||
unitId: Number(resolvedUnitId),
|
unitId: resolvedUnitId ? Number(resolvedUnitId) : null,
|
||||||
|
groupId: Number.isFinite(groupIdRaw) && groupIdRaw > 0 ? groupIdRaw : null,
|
||||||
sectionOrderIndex: sIdx,
|
sectionOrderIndex: sIdx,
|
||||||
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
||||||
progressionGraphId: null,
|
progressionGraphId: null,
|
||||||
plannedExerciseIds,
|
plannedExerciseIds,
|
||||||
}
|
}
|
||||||
}, [editingUnit?.id, unitId, exercisePickerTarget, formData.sections])
|
}, [editingUnit?.id, unitId, exercisePickerTarget, formData.sections, formData.group_id])
|
||||||
|
|
||||||
|
const openExercisePicker = useCallback(({ sectionIndex, itemIndex, insertBeforeIndex, pickerMode = 'library' }) => {
|
||||||
|
setExercisePickerTarget({
|
||||||
|
sIdx: sectionIndex,
|
||||||
|
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
|
||||||
|
insertBeforeIndex:
|
||||||
|
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
|
||||||
|
? insertBeforeIndex
|
||||||
|
: undefined,
|
||||||
|
pickerMode,
|
||||||
|
})
|
||||||
|
setExercisePickerOpen(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const goBack = useCallback(() => {
|
const goBack = useCallback(() => {
|
||||||
goNavReturn(navigate, location, {
|
goNavReturn(navigate, location, {
|
||||||
|
|
@ -739,17 +762,8 @@ export default function TrainingUnitEditPage() {
|
||||||
onRequestPublishToFramework={() => editingUnit?.id && setPublishFrameworkOpen(true)}
|
onRequestPublishToFramework={() => editingUnit?.id && setPublishFrameworkOpen(true)}
|
||||||
onRequestSaveAsModule={() => editingUnit?.id && setSaveModuleOpen(true)}
|
onRequestSaveAsModule={() => editingUnit?.id && setSaveModuleOpen(true)}
|
||||||
onRequestTrainingModulePick={(ctx) => void openModuleApplyModal(ctx)}
|
onRequestTrainingModulePick={(ctx) => void openModuleApplyModal(ctx)}
|
||||||
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => {
|
onRequestExercisePick={(ctx) => openExercisePicker({ ...ctx, pickerMode: 'library' })}
|
||||||
setExercisePickerTarget({
|
onRequestPlanningExercisePick={(ctx) => openExercisePicker({ ...ctx, pickerMode: 'planning' })}
|
||||||
sIdx: sectionIndex,
|
|
||||||
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
|
|
||||||
insertBeforeIndex:
|
|
||||||
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
|
|
||||||
? insertBeforeIndex
|
|
||||||
: undefined,
|
|
||||||
})
|
|
||||||
setExercisePickerOpen(true)
|
|
||||||
}}
|
|
||||||
onPeekExercise={(id, variantId, peekExtras) =>
|
onPeekExercise={(id, variantId, peekExtras) =>
|
||||||
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null, peekExtras: peekExtras ?? null })
|
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null, peekExtras: peekExtras ?? null })
|
||||||
}
|
}
|
||||||
|
|
@ -804,6 +818,8 @@ export default function TrainingUnitEditPage() {
|
||||||
multiSelect
|
multiSelect
|
||||||
enableQuickCreateDraft
|
enableQuickCreateDraft
|
||||||
expectPlanningSearch
|
expectPlanningSearch
|
||||||
|
pickerMode={exercisePickerTarget?.pickerMode === 'planning' ? 'planning' : 'library'}
|
||||||
|
enableFreePlanningSearch
|
||||||
planningUnitId={
|
planningUnitId={
|
||||||
editingUnit?.id ??
|
editingUnit?.id ??
|
||||||
(Number.isFinite(unitId) && unitId > 0 ? unitId : null)
|
(Number.isFinite(unitId) && unitId > 0 ? unitId : null)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user