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):
|
||||
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)
|
||||
phase_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(
|
||||
cur,
|
||||
group_id: Optional[int],
|
||||
exclude_unit_id: int,
|
||||
exclude_unit_id: Optional[int] = None,
|
||||
limit: int = 40,
|
||||
) -> Set[int]:
|
||||
if not group_id:
|
||||
return set()
|
||||
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 tu.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), int(exclude_unit_id)),
|
||||
)
|
||||
if exclude_unit_id is not None:
|
||||
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 tu.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), 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()
|
||||
for r in cur.fetchall():
|
||||
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(
|
||||
cur,
|
||||
*,
|
||||
tenant: TenantContext,
|
||||
body: PlanningExerciseSuggestRequest,
|
||||
) -> 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)
|
||||
query = _normalize_query(body.query)
|
||||
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_exercise_id": pack.get("anchor_exercise_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 {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.171"
|
||||
APP_VERSION = "0.8.172"
|
||||
BUILD_DATE = "2026-05-22"
|
||||
DB_SCHEMA_VERSION = "20260531073"
|
||||
|
||||
|
|
@ -28,7 +28,7 @@ MODULE_VERSIONS = {
|
|||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"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_programs": "0.1.0",
|
||||
"planning": "0.15.0", # Vorlagen: Strukturvorschau, Bearbeiten inkl. Split-Sessions + Beschreibung
|
||||
|
|
@ -43,6 +43,14 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
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",
|
||||
"date": "2026-05-22",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,11 @@ export default function ExercisePickerModal({
|
|||
enableQuickCreateDraft = false,
|
||||
/** Gespeicherte training_units.id — aktiviert Planungs-KI (robuster als nur planningContext). */
|
||||
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,
|
||||
/** Planungs-Kontext für KI-Suche (Abschnitt, Anker, Plan …) */
|
||||
planningContext = null,
|
||||
|
|
@ -84,9 +88,11 @@ export default function ExercisePickerModal({
|
|||
}, [planningUnitId, planningContext?.unitId])
|
||||
|
||||
const activePlanningContext = useMemo(() => {
|
||||
if (!resolvedPlanningUnitId) return null
|
||||
return {
|
||||
unitId: resolvedPlanningUnitId,
|
||||
if (pickerMode !== 'planning') return null
|
||||
const groupIdRaw = planningContext?.groupId
|
||||
const groupId = Number(groupIdRaw)
|
||||
const base = {
|
||||
groupId: Number.isFinite(groupId) && groupId > 0 ? groupId : null,
|
||||
sectionOrderIndex: planningContext?.sectionOrderIndex ?? 0,
|
||||
phaseOrderIndex: planningContext?.phaseOrderIndex ?? null,
|
||||
parallelStreamOrderIndex: planningContext?.parallelStreamOrderIndex ?? null,
|
||||
|
|
@ -97,10 +103,24 @@ export default function ExercisePickerModal({
|
|||
: [],
|
||||
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 planningSearchBlocked = Boolean(expectPlanningSearch && !usePlanningSearch)
|
||||
const usePlanningSearch = pickerMode === 'planning' && activePlanningContext != null
|
||||
const useFreePlanningSearch = usePlanningSearch && !resolvedPlanningUnitId
|
||||
const planningSearchBlocked = Boolean(
|
||||
pickerMode === 'planning' &&
|
||||
expectPlanningSearch &&
|
||||
!resolvedPlanningUnitId &&
|
||||
!enableFreePlanningSearch
|
||||
)
|
||||
|
||||
/** Gemeinsamer Suchtext — in Planung nur ein Feld; in Bibliothek beide Felder kombiniert. */
|
||||
const effectivePickerQuery = useMemo(() => {
|
||||
|
|
@ -306,8 +326,7 @@ export default function ExercisePickerModal({
|
|||
try {
|
||||
if (usePlanningSearch && activePlanningContext) {
|
||||
const query = effectivePickerQuery
|
||||
const res = await api.suggestPlanningExercises({
|
||||
unit_id: Number(activePlanningContext.unitId),
|
||||
const requestBody = {
|
||||
section_order_index:
|
||||
activePlanningContext.sectionOrderIndex != null
|
||||
? Number(activePlanningContext.sectionOrderIndex)
|
||||
|
|
@ -336,13 +355,20 @@ export default function ExercisePickerModal({
|
|||
.filter((x) => Number.isFinite(x) && x > 0)
|
||||
: undefined,
|
||||
include_llm_intent: Boolean(query),
|
||||
include_llm_rank: true,
|
||||
include_llm_rank: Boolean(query),
|
||||
query,
|
||||
intent_hint: activePlanningContext.intentHint || null,
|
||||
intent_hint: activePlanningContext.intentHint || (useFreePlanningSearch && query ? 'free_search' : null),
|
||||
limit: PAGE_SIZE,
|
||||
exercise_kind_any:
|
||||
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)
|
||||
setPlanningTargetProfileSummary(res?.target_profile_summary || null)
|
||||
setPlanningLlmRankApplied(Boolean(res?.llm_rank_applied))
|
||||
|
|
@ -397,6 +423,8 @@ export default function ExercisePickerModal({
|
|||
activePlanningContext,
|
||||
effectivePickerQuery,
|
||||
exerciseKindAny,
|
||||
resolvedPlanningUnitId,
|
||||
useFreePlanningSearch,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -643,12 +671,11 @@ export default function ExercisePickerModal({
|
|||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
<strong>Planungs-KI noch nicht verfügbar.</strong> Die Einheit hat noch keine gespeicherte ID —
|
||||
bitte zuerst <strong>Speichern</strong>, dann den Übungspicker erneut öffnen. Bis dahin gilt nur die
|
||||
Bibliothekssuche (Volltext).
|
||||
<strong>Planungs-KI noch nicht verfügbar.</strong> Bitte zuerst <strong>Speichern</strong> oder den
|
||||
Menüpunkt <strong>Planungs-KI: Übung vorschlagen</strong> nutzen (Freitext ohne gespeicherte Einheit).
|
||||
</p>
|
||||
) : null}
|
||||
{!usePlanningSearch && !planningSearchBlocked ? (
|
||||
{pickerMode === 'library' && expectPlanningSearch ? (
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 10px',
|
||||
|
|
@ -660,9 +687,40 @@ export default function ExercisePickerModal({
|
|||
color: 'var(--text2)',
|
||||
}}
|
||||
>
|
||||
<strong style={{ color: 'var(--text1)' }}>Bibliothekssuche (Volltext)</strong> — Planungs-KI mit
|
||||
Kontext (Einheit, Plan, Anker) gibt es in der{' '}
|
||||
<strong>Trainingseinheit bearbeiten</strong>, nach dem Speichern der Einheit.
|
||||
<strong style={{ color: 'var(--text1)' }}>Bibliothekssuche (Volltext)</strong> — für Planungs-KI mit
|
||||
Kontext oder Freitext-Anfrage den Menüpunkt{' '}
|
||||
<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>
|
||||
) : null}
|
||||
<div style={{ display: 'grid', gap: '0.65rem' }}>
|
||||
|
|
|
|||
|
|
@ -253,6 +253,7 @@ export default function TrainingUnitSectionsEditor({
|
|||
sections,
|
||||
onSectionsChange,
|
||||
onRequestExercisePick,
|
||||
onRequestPlanningExercisePick,
|
||||
onRequestTrainingModulePick,
|
||||
onPeekExercise,
|
||||
showExecutionExtras = false,
|
||||
|
|
@ -2591,12 +2592,23 @@ export default function TrainingUnitSectionsEditor({
|
|||
</p>
|
||||
) : (
|
||||
<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
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
onClick={() => onRequestExercisePick?.({ sectionIndex: sIdx })}
|
||||
>
|
||||
+ Übung
|
||||
+ Übung (Bibliothek)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -2807,9 +2819,25 @@ export default function TrainingUnitSectionsEditor({
|
|||
ändern.
|
||||
</p>
|
||||
<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
|
||||
type="button"
|
||||
className="btn btn-primary tu-insert-chooser-actions__full"
|
||||
className={`btn ${onRequestPlanningExercisePick ? 'btn-secondary' : 'btn-primary'} tu-insert-chooser-actions__full`}
|
||||
onClick={() => {
|
||||
const { sIdx, beforeIx } = insertChooser
|
||||
closeInsertChooser()
|
||||
|
|
@ -2819,7 +2847,7 @@ export default function TrainingUnitSectionsEditor({
|
|||
})
|
||||
}}
|
||||
>
|
||||
Übung auswählen …
|
||||
{onRequestPlanningExercisePick ? 'Übung aus Bibliothek …' : 'Übung auswählen …'}
|
||||
</button>
|
||||
{onRequestTrainingModulePick ? (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export default function TrainingUnitFormShell({
|
|||
onRequestSaveAsModule,
|
||||
onRequestTrainingModulePick,
|
||||
onRequestExercisePick,
|
||||
onRequestPlanningExercisePick,
|
||||
onPeekExercise,
|
||||
formId = 'planning-unit-form',
|
||||
}) {
|
||||
|
|
@ -427,6 +428,7 @@ export default function TrainingUnitFormShell({
|
|||
}
|
||||
onRequestTrainingModulePick={onRequestTrainingModulePick}
|
||||
onRequestExercisePick={onRequestExercisePick}
|
||||
onRequestPlanningExercisePick={onRequestPlanningExercisePick}
|
||||
onPeekExercise={onPeekExercise}
|
||||
showExecutionExtras={Boolean(editingUnit) && sectionsEditMode === 'debrief'}
|
||||
enableParallelPhaseControls
|
||||
|
|
|
|||
|
|
@ -238,6 +238,76 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
const [targetGroupsCatalog, setTargetGroupsCatalog] = useState([])
|
||||
const [sectionPickerCtx, setSectionPickerCtx] = 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 [goalMenuGi, setGoalMenuGi] = useState(null)
|
||||
/** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */
|
||||
|
|
@ -911,17 +981,8 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
),
|
||||
}))
|
||||
}}
|
||||
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) =>
|
||||
setSectionPickerCtx({
|
||||
slotIdx: si,
|
||||
sectionIndex,
|
||||
itemIndex: typeof itemIndex === 'number' ? itemIndex : undefined,
|
||||
insertBeforeIndex:
|
||||
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
|
||||
? insertBeforeIndex
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
onRequestExercisePick={(ctx) => openFrameworkExercisePicker(si, ctx, 'library')}
|
||||
onRequestPlanningExercisePick={(ctx) => openFrameworkExercisePicker(si, ctx, 'planning')}
|
||||
onPeekExercise={(id, variantId) =>
|
||||
setPeekCtx({ exerciseId: id, variantId: variantId ?? null })
|
||||
}
|
||||
|
|
@ -1366,6 +1427,9 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
open={sectionPickerCtx != null}
|
||||
multiSelect
|
||||
enableQuickCreateDraft
|
||||
pickerMode={sectionPickerCtx?.pickerMode === 'planning' ? 'planning' : 'library'}
|
||||
enableFreePlanningSearch
|
||||
planningContext={frameworkPlanningContext}
|
||||
onClose={() => setSectionPickerCtx(null)}
|
||||
onSelectExercises={async (picked) => {
|
||||
if (!sectionPickerCtx || !picked?.length) return
|
||||
|
|
|
|||
|
|
@ -124,9 +124,9 @@ export default function TrainingUnitEditPage() {
|
|||
const [saveModuleOpen, setSaveModuleOpen] = useState(false)
|
||||
|
||||
const exercisePickerPlanningContext = useMemo(() => {
|
||||
if (!exercisePickerTarget) return null
|
||||
const resolvedUnitId =
|
||||
editingUnit?.id ?? (Number.isFinite(unitId) && unitId > 0 ? unitId : null)
|
||||
if (!resolvedUnitId) return null
|
||||
const target = exercisePickerTarget
|
||||
const secs = formData.sections || []
|
||||
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 {
|
||||
for (let i = sec.items.length - 1; i >= 0; i -= 1) {
|
||||
if (sec.items[i]?.exercise_id) {
|
||||
|
|
@ -165,14 +173,29 @@ export default function TrainingUnitEditPage() {
|
|||
plannedExerciseIds.push(eid)
|
||||
}
|
||||
}
|
||||
const groupIdRaw = Number(formData.group_id)
|
||||
return {
|
||||
unitId: Number(resolvedUnitId),
|
||||
unitId: resolvedUnitId ? Number(resolvedUnitId) : null,
|
||||
groupId: Number.isFinite(groupIdRaw) && groupIdRaw > 0 ? groupIdRaw : null,
|
||||
sectionOrderIndex: sIdx,
|
||||
anchorExerciseId: Number.isFinite(anchorExerciseId) && anchorExerciseId > 0 ? anchorExerciseId : null,
|
||||
progressionGraphId: null,
|
||||
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(() => {
|
||||
goNavReturn(navigate, location, {
|
||||
|
|
@ -739,17 +762,8 @@ export default function TrainingUnitEditPage() {
|
|||
onRequestPublishToFramework={() => editingUnit?.id && setPublishFrameworkOpen(true)}
|
||||
onRequestSaveAsModule={() => editingUnit?.id && setSaveModuleOpen(true)}
|
||||
onRequestTrainingModulePick={(ctx) => void openModuleApplyModal(ctx)}
|
||||
onRequestExercisePick={({ sectionIndex, itemIndex, insertBeforeIndex }) => {
|
||||
setExercisePickerTarget({
|
||||
sIdx: sectionIndex,
|
||||
iIdx: typeof itemIndex === 'number' ? itemIndex : undefined,
|
||||
insertBeforeIndex:
|
||||
typeof insertBeforeIndex === 'number' && Number.isFinite(insertBeforeIndex)
|
||||
? insertBeforeIndex
|
||||
: undefined,
|
||||
})
|
||||
setExercisePickerOpen(true)
|
||||
}}
|
||||
onRequestExercisePick={(ctx) => openExercisePicker({ ...ctx, pickerMode: 'library' })}
|
||||
onRequestPlanningExercisePick={(ctx) => openExercisePicker({ ...ctx, pickerMode: 'planning' })}
|
||||
onPeekExercise={(id, variantId, peekExtras) =>
|
||||
setPlanningPeekCtx({ exerciseId: id, variantId: variantId ?? null, peekExtras: peekExtras ?? null })
|
||||
}
|
||||
|
|
@ -804,6 +818,8 @@ export default function TrainingUnitEditPage() {
|
|||
multiSelect
|
||||
enableQuickCreateDraft
|
||||
expectPlanningSearch
|
||||
pickerMode={exercisePickerTarget?.pickerMode === 'planning' ? 'planning' : 'library'}
|
||||
enableFreePlanningSearch
|
||||
planningUnitId={
|
||||
editingUnit?.id ??
|
||||
(Number.isFinite(unitId) && unitId > 0 ? unitId : null)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user