KPI-Scroing, Filter, etc, #43

Merged
Lars merged 10 commits from develop into main 2026-05-21 10:36:49 +02:00
32 changed files with 4607 additions and 379 deletions

View File

@ -0,0 +1,96 @@
# Gewichtetes Fähigkeiten-Scoring (Phase 3)
**Stand:** 2026-05-20
**Status:** Variante A (regelbasiert) umgesetzt — **v1.2** (Kategorien-Gruppierung + universelle Skala)
**Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
## Ziel
Trainer wählen **Schwerpunkt-Fähigkeiten** und erhalten Vorschläge für **Rahmenprogramme**, **Trainingsmodule** und **Regressionspfade** (Progressionsgraphen), deren Übungen diese Fähigkeiten stark abdecken.
## Datenquellen
| Artefakt | Übungen aus |
|----------|-------------|
| Rahmenprogramm (gesamt) | Alle Blueprint-`training_units` der Slots → `training_unit_section_items` |
| Rahmenprogramm (pro Slot) | Blueprint einer Session |
| Trainingsmodul | `training_module_items` (nur `item_type = exercise`) |
| Progressionsgraph | `from_exercise_id` + `to_exercise_id` je Kante (Vorkommen zählt) |
Fähigkeiten je Übung: `exercise_skills``skills` (nur `status = active`).
## Gewichtungsformel (v1.1)
Pro **Übungsvorkommen** (eine Zeile im Ablauf / Modul / Kanten-Endpunkt):
1. **Basis-Minuten** = `planned_duration_min` der Position, sonst Default (Einheit/Modul: 8 Min, Graph: 10 Min).
2. Pro verknüpfte Fähigkeit der Übung:
- `Beitrag = Basis-Minuten × Anzahl Vorkommen × Link-Faktor`
- **Link-Faktor** = Intensität × Stufen-Faktor
### Intensität (Nutzeneinschätzung, UI-Feld)
| Wert | Faktor |
|------|--------|
| niedrig | 0,85 |
| mittel / leer | 1,0 |
| hoch | 1,2 |
### Stufen-Spanne (`required_level` → `target_level`, UI „von/bis“)
Kanonische Slugs: basis … optimierung (15). Fehlen beide: Faktor 1,0.
- **Spanne** = Anzahl Stufen von „von“ bis „bis“ (15)
- **Mittelpunkt** = durchschnittliche Stufe
- Faktor ≈ `(0,92 + 0,04 × Spanne) × (0,95 + 0,025 × Mittelpunkt)` → typisch 0,961,20
Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1).
### Bewusst nicht im Scoring
| Feld | Grund |
|------|--------|
| `is_primary` | Perspektivabhängig; bleibt in Übungs-UI, fließt nicht ins Profil ein |
| `development_contribution` | Legacy-DB-Feld, in UI nicht gepflegt |
Aggregation:
- Summe pro `skill_id``weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**)
- `artifact_share_percent` / `share_percent` = Anteil an `total_weight` **innerhalb dieses Artefakts** (summiert 100 % — nur noch sekundär)
- `by_main_category[]` → je Unterkategorie `top_skill` (stärkste Fähigkeit nach absolutem Gewicht)
- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus **Vereins-Artefakten** (`visibility=club`, aktiver Verein): Rahmenprogramme, Module, Regressionspfade
- `club_best` / `club_best_by_skill`: stärkstes Vereins-Element je Fähigkeit (Titel, Typ, Gewicht)
- Listen: `POST /api/skill-profiles/batch-summaries` — ein Corpus-Durchlauf, kompakte Profile für viele IDs
Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil.
## API
| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| GET | `/api/training-framework-programs/{id}/skill-profile` | `overall` + `slots[]` mit je `profile` |
| GET | `/api/training-modules/{id}/skill-profile` | `overall` |
| GET | `/api/exercise-progression-graphs/{id}/skill-profile` | `overall` |
| GET | `/api/skill-discovery/suggestions?skill_ids=1,2,3` | Ranking sichtbarer Artefakte; Query `types`, `limit` |
Zugriff: `get_tenant_context` + gleiche Sichtbarkeit wie Parent-Artefakt (`library_content_visibility_sql`).
## UI
- **Rahmenprogramm bearbeiten:** Panel „Fähigkeiten-Schwerpunkte“, inkl. Aufklapp pro Session
- **Trainingsmodul bearbeiten:** Panel „Fähigkeiten im Modul“
- **Progressionsgraph:** Panel „Fähigkeiten entlang des Pfads“
- **Fähigkeiten-Seite → Planungs-Vorschläge:** Multi-Select + Bibliothekssuche
Profil wird nach **Speichern** neu geladen (`skillProfileTick`).
## Grenzen / später
- Kein Cache in DB (`skill_profile_json`) — on-the-fly; bei Performance >50 Artefakte serverseitiger Index
- Entwicklungsziele am Rahmenkopf bleiben Freitext (kein Scoring)
- KI-Zusammenfassung (Variante B Roadmap) nicht Teil von v1.0
- Trainings**einheiten** (Kalender) optional als nächste Erweiterung
## Tests
- `backend/tests/test_skill_scoring.py` — Multiplikator, Aggregation, Match-Score

View File

@ -1,7 +1,7 @@
# Rahmenprogramm: Filter, Dauer, Fähigkeiten-Schwerpunkte (Roadmap)
**Stand:** 2026-05-20
**Status:** Phase 1 umgesetzt (Listen + Import-Filter); Phase 23 offen
**Status:** Phase 1 umgesetzt; Phase 3 v1.0 umgesetzt (regelbasiert); Phase 2 teilweise offen
## Phase 1 (umgesetzt)
@ -29,23 +29,16 @@
**API-Erweiterung (optional):** `GET /api/training-framework-programs?focus_area_id=&training_type_id=&duration_min=` serverseitig — sinnvoll ab >50 Rahmen in der Bibliothek.
## Phase 3 — Fähigkeiten aus Übungen (Schwerpunkte dynamisch)
## Phase 3 — Fähigkeiten aus Übungen (umgesetzt v1.0)
### Ziel
**Spec:** `.claude/docs/technical/SKILL_SCORING_SPEC.md`
Aus allen Übungen in allen Slots eines Rahmenprogramms die verknüpften **Fähigkeiten** (`exercise_skills` → `skills`, ggf. Fokusbereich der Fähigkeit) aggregieren, gewichten und als **Vorschlags-Schwerpunkte** oder Metadaten am Rahmen anzeigen (nicht zwingend automatisch in den Kopf schreiben).
- Gewichtetes Profil: Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph
- `GET /api/skill-discovery/suggestions?skill_ids=…` für Bibliotheks-Vorschläge
- UI: Profil-Panels in Editoren + Tab „Planungs-Vorschläge“ auf der Fähigkeiten-Seite
- **Kein** automatisches Überschreiben der Stammdaten-Fokusbereiche
### Variante A — Regelbasiert (ohne KI)
1. Pro Blueprint-Unit alle `exercise_id` aus `training_unit_section_items` sammeln.
2. Join `exercise_skills` (optional Gewicht: `planned_duration_min` der Zeile, Anzahl Vorkommen, Primär-Fähigkeit).
3. Top-N Fähigkeiten / Fokusbereiche nach Summe oder Anteil an Gesamtminuten.
4. Ergebnis cachen in `training_framework_programs.skill_profile_json` (Migration) oder nur on-the-fly bei GET Detail.
**Vorteil:** reproduzierbar, offline, Governance-konform.
**Aufwand:** ca. 12 Tage Backend + kleine UI-Karte „Fähigkeiten-Profil (aus Übungen)“.
### Variante B — KI-Zusammenfassung (OpenRouter, optional)
### Variante B — KI-Zusammenfassung (OpenRouter, optional, offen)
1. Input: Titel Rahmen, Ziele (Text), Liste Übungstitel + Dauer + vorhandene Skill-Namen.
2. Prompt: strukturiertes JSON (`suggested_focus_areas[]`, `skill_emphasis[]`, `rationale_de`).

View File

@ -193,7 +193,7 @@ def read_root():
return out
# Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
app.include_router(auth.router)
app.include_router(profiles.router)
@ -208,6 +208,7 @@ app.include_router(media_assets.router)
app.include_router(media_assets.admin_rights_router)
app.include_router(media_assets.admin_legal_hold_router)
app.include_router(skills.router)
app.include_router(skill_profiles.router)
app.include_router(training_planning.router)
app.include_router(dashboard.router)
app.include_router(training_modules.router)

View File

@ -0,0 +1,689 @@
"""
Fähigkeiten-Profile und Vorschläge (Phase 3) für Planungsartefakte.
GET /skill-profile gewichtetes Profil aus verknüpften Übungen.
GET /api/skill-discovery/suggestions Rahmenprogramme, Module, Progressionsgraphen nach Fähigkeiten.
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from db import get_db, get_cursor, r2d
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
from skill_scoring import (
GRAPH_DEFAULT_ITEM_MINUTES,
ExerciseOccurrence,
batch_compute_profiles,
batch_framework_occurrences_by_id,
batch_module_occurrences_by_id,
collect_module_exercise_occurrences,
collect_progression_graph_exercise_occurrences,
collect_unit_exercise_occurrences,
compact_profile_summary,
compute_planning_corpus_by_type,
compute_club_corpus_reference,
compute_corpus_skill_max_weights,
compute_skill_profile,
corpus_for_artifact_type,
fetch_exercise_skills_bulk,
match_score_for_skill_ids,
profile_for_occurrences,
reference_scale_meta,
top_categories_summary,
)
from routers.training_framework_programs import _framework_access
from routers.training_modules import _module_access
from routers.exercise_progression_graphs import _require_graph_read
router = APIRouter(prefix="/api", tags=["skill_profiles"])
def _parse_skill_ids_param(raw: Optional[str]) -> List[int]:
if not raw or not str(raw).strip():
return []
out: List[int] = []
for part in str(raw).split(","):
part = part.strip()
if not part:
continue
try:
n = int(part)
except ValueError:
raise HTTPException(status_code=400, detail="skill_ids: ungültige ID") from None
if n > 0 and n not in out:
out.append(n)
return out
@router.get("/training-framework-programs/{framework_id}/skill-profile")
def framework_program_skill_profile(
framework_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row = _framework_access(cur, framework_id, profile_id, role)
cur.execute(
"""
SELECT s.id, s.sort_order, s.title,
tu.id AS blueprint_unit_id
FROM training_framework_slots s
LEFT JOIN training_units tu ON tu.framework_slot_id = s.id
WHERE s.framework_program_id = %s
ORDER BY s.sort_order
""",
(framework_id,),
)
slots_raw = [r2d(r) for r in cur.fetchall()]
bundle = _load_planning_corpus(cur, tenant)
tc = corpus_for_artifact_type(bundle, "framework_program")
ref_max = tc["max_by_skill"]
ref_by_skill = tc["ref_by_skill"]
all_occurrences: List[ExerciseOccurrence] = []
slot_profiles: List[Dict[str, Any]] = []
for slot in slots_raw:
uid = slot.get("blueprint_unit_id")
slot_occ: List[ExerciseOccurrence] = []
slot_label = (slot.get("title") or "").strip() or f"Session {(slot.get('sort_order') or 0) + 1}"
if uid:
raw_occ = collect_unit_exercise_occurrences(cur, int(uid))
slot_occ = [
ExerciseOccurrence(
exercise_id=o.exercise_id,
planned_duration_min=o.planned_duration_min,
context_label=slot_label,
)
for o in raw_occ
]
all_occurrences.extend(slot_occ)
else:
slot_occ = []
slot_profile = (
profile_for_occurrences(cur, slot_occ, reference_max_by_skill=ref_max)
if slot_occ
else _empty_profile()
)
slot_profiles.append(
{
"slot_id": slot["id"],
"slot_title": slot.get("title"),
"sort_order": slot.get("sort_order"),
"blueprint_training_unit_id": uid,
"exercise_occurrence_count": len(slot_occ),
"profile": slot_profile,
}
)
overall = (
profile_for_occurrences(cur, all_occurrences, reference_max_by_skill=ref_max)
if all_occurrences
else _empty_profile()
)
_enrich_profile_club_best(overall, ref_by_skill, "framework_program", framework_id)
for slot in slot_profiles:
_enrich_profile_club_best(
slot.get("profile") or {},
ref_by_skill,
"framework_program",
framework_id,
)
return {
"artifact_type": "framework_program",
"artifact_id": framework_id,
"artifact_title": row.get("title"),
"reference_scale": reference_scale_meta(
tc, "framework_program", effective_club_id=tenant.effective_club_id
),
"club_best_by_skill": {
str(k): v for k, v in ref_by_skill.items()
},
"overall": overall,
"slots": slot_profiles,
}
@router.get("/training-modules/{module_id}/skill-profile")
def training_module_skill_profile(
module_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row = _module_access(cur, module_id, profile_id, role)
bundle = _load_planning_corpus(cur, tenant)
tc = corpus_for_artifact_type(bundle, "training_module")
ref_max = tc["max_by_skill"]
ref_by_skill = tc["ref_by_skill"]
occurrences = collect_module_exercise_occurrences(cur, module_id)
overall = (
profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
if occurrences
else _empty_profile()
)
_enrich_profile_club_best(overall, ref_by_skill, "training_module", module_id)
return {
"artifact_type": "training_module",
"artifact_id": module_id,
"artifact_title": row.get("title"),
"reference_scale": reference_scale_meta(
tc, "training_module", effective_club_id=tenant.effective_club_id
),
"club_best_by_skill": {
str(k): v for k, v in ref_by_skill.items()
},
"overall": overall,
}
@router.get("/exercise-progression-graphs/{graph_id}/skill-profile")
def progression_graph_skill_profile(
graph_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
row = _require_graph_read(cur, graph_id, profile_id, role)
bundle = _load_planning_corpus(cur, tenant)
tc = corpus_for_artifact_type(bundle, "progression_graph")
ref_max = tc["max_by_skill"]
ref_by_skill = tc["ref_by_skill"]
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
overall = (
profile_for_occurrences(
cur,
occurrences,
default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES,
reference_max_by_skill=ref_max,
)
if occurrences
else _empty_profile()
)
_enrich_profile_club_best(overall, ref_by_skill, "progression_graph", graph_id)
return {
"artifact_type": "progression_graph",
"artifact_id": graph_id,
"artifact_title": row.get("name"),
"reference_scale": reference_scale_meta(
tc, "progression_graph", effective_club_id=tenant.effective_club_id
),
"club_best_by_skill": {
str(k): v for k, v in ref_by_skill.items()
},
"overall": overall,
}
@router.post("/skill-profiles/batch-summaries")
def batch_skill_profile_summaries(
data: dict,
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Kompakte Fähigkeiten-Profile für Listen (ein Corpus-Scan, Batch-SQL).
Body: { framework_program_ids?: number[], training_module_ids?: number[] }
"""
fp_ids = _parse_id_list(data.get("framework_program_ids"))
mod_ids = _parse_id_list(data.get("training_module_ids"))
if not fp_ids and not mod_ids:
raise HTTPException(
status_code=400,
detail="framework_program_ids oder training_module_ids erforderlich",
)
profile_id = tenant.profile_id
role = tenant.global_role
summaries: Dict[str, Dict[str, Any]] = {}
with get_db() as conn:
cur = get_cursor(conn)
bundle = compute_planning_corpus_by_type(
cur,
profile_id=tenant.profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
include_artifact_summaries=True,
)
allowed_fp: List[int] = []
if fp_ids:
for fid in fp_ids:
try:
_framework_access(cur, fid, profile_id, role)
allowed_fp.append(fid)
except HTTPException:
pass
allowed_mod: List[int] = []
if mod_ids:
for mid in mod_ids:
try:
_module_access(cur, mid, profile_id, role)
allowed_mod.append(mid)
except HTTPException:
pass
summaries = _merge_batch_summaries(
cur,
bundle=bundle,
allowed_fp=allowed_fp,
allowed_mod=allowed_mod,
)
ref_by_skill = {}
for t in ("framework_program", "training_module", "progression_graph"):
ref_by_skill.update(corpus_for_artifact_type(bundle, t).get("ref_by_skill") or {})
skill_ids_seen: set[int] = set()
for summary in summaries.values():
for sk in summary.get("skills") or []:
if sk.get("skill_id") is not None:
skill_ids_seen.add(int(sk["skill_id"]))
club_best_subset = {
str(sid): ref_by_skill[sid]
for sid in skill_ids_seen
if sid in ref_by_skill
}
return {
"reference_scale_by_type": {
t: reference_scale_meta(
corpus_for_artifact_type(bundle, t),
t,
effective_club_id=tenant.effective_club_id,
)
for t in ("framework_program", "training_module", "progression_graph")
},
"club_best_by_skill": club_best_subset,
"summaries": summaries,
}
@router.get("/skill-discovery/suggestions")
def skill_discovery_suggestions(
skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
types: Optional[str] = Query(
default="framework_program,training_module,progression_graph",
description="Artefakttypen, komma-getrennt",
),
limit: int = Query(default=20, ge=1, le=50),
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Findet Bibliotheksartefakte, deren Übungs-Fähigkeiten-Profil die gewünschten Fähigkeiten stark abdeckt.
"""
wanted = _parse_skill_ids_param(skill_ids)
if not wanted:
raise HTTPException(status_code=400, detail="skill_ids ist Pflicht (mindestens eine ID)")
type_set = {t.strip() for t in (types or "").split(",") if t.strip()}
profile_id = tenant.profile_id
role = tenant.global_role
results: List[Dict[str, Any]] = []
with get_db() as conn:
cur = get_cursor(conn)
planning_bundle = _load_planning_corpus(cur, tenant)
fw_ref = corpus_for_artifact_type(planning_bundle, "framework_program")["max_by_skill"]
mod_ref = corpus_for_artifact_type(planning_bundle, "training_module")["max_by_skill"]
graph_ref = corpus_for_artifact_type(planning_bundle, "progression_graph")["max_by_skill"]
if "framework_program" in type_set:
vis_clause, vis_params = library_content_visibility_sql(
alias="fp",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
f"""
SELECT fp.id, fp.title
FROM training_framework_programs fp
WHERE ({vis_clause})
ORDER BY fp.updated_at DESC NULLS LAST
LIMIT 80
""",
vis_params,
)
for fp_row in cur.fetchall():
fid = int(fp_row["id"])
try:
_framework_access(cur, fid, profile_id, role)
except HTTPException:
continue
cur.execute(
"""
SELECT tu.id
FROM training_framework_slots s
INNER JOIN training_units tu ON tu.framework_slot_id = s.id
WHERE s.framework_program_id = %s
""",
(fid,),
)
occ: List[ExerciseOccurrence] = []
for u in cur.fetchall():
occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
if not occ:
continue
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=fw_ref)
match = match_score_for_skill_ids(prof, wanted)
if match["match_weight"] <= 0:
continue
results.append(
{
"artifact_type": "framework_program",
"artifact_id": fid,
"artifact_title": fp_row["title"],
"path": f"/planning/framework-programs/{fid}",
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
"top_by_category": top_categories_summary(prof),
},
}
)
if "training_module" in type_set:
vis_clause, vis_params = library_content_visibility_sql(
alias="m",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
f"""
SELECT m.id, m.title
FROM training_modules m
WHERE ({vis_clause})
ORDER BY m.updated_at DESC NULLS LAST
LIMIT 80
""",
vis_params,
)
for m_row in cur.fetchall():
mid = int(m_row["id"])
try:
_module_access(cur, mid, profile_id, role)
except HTTPException:
continue
occ = collect_module_exercise_occurrences(cur, mid)
if not occ:
continue
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=mod_ref)
match = match_score_for_skill_ids(prof, wanted)
if match["match_weight"] <= 0:
continue
results.append(
{
"artifact_type": "training_module",
"artifact_id": mid,
"artifact_title": m_row["title"],
"path": f"/planning/training-modules/{mid}",
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
"top_by_category": top_categories_summary(prof),
},
}
)
if "progression_graph" in type_set:
vis_clause, vis_params = library_content_visibility_sql(
alias="g",
profile_id=profile_id,
role=role,
effective_club_id=tenant.effective_club_id,
)
cur.execute(
f"""
SELECT g.id, g.name
FROM exercise_progression_graphs g
WHERE ({vis_clause})
ORDER BY g.updated_at DESC NULLS LAST
LIMIT 80
""",
vis_params,
)
for g_row in cur.fetchall():
gid = int(g_row["id"])
try:
_require_graph_read(cur, gid, profile_id, role)
except HTTPException:
continue
occ = collect_progression_graph_exercise_occurrences(cur, gid)
if not occ:
continue
prof = profile_for_occurrences(
cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES,
reference_max_by_skill=graph_ref,
)
match = match_score_for_skill_ids(prof, wanted)
if match["match_weight"] <= 0:
continue
results.append(
{
"artifact_type": "progression_graph",
"artifact_id": gid,
"artifact_title": g_row["name"],
"path": None,
"match": match,
"skill_profile_summary": {
"total_score": prof.get("total_score"),
"top_by_category": top_categories_summary(prof),
},
}
)
results.sort(
key=lambda x: -float(x.get("match", {}).get("match_score") or x.get("match", {}).get("match_weight") or 0),
)
return {
"skill_ids": wanted,
"types": sorted(type_set),
"suggestions": results[:limit],
}
def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]:
"""Kurzliste Top-Fähigkeit je Unterkategorie für Discovery-Treffer."""
out: List[Dict[str, Any]] = []
for mc in profile.get("by_main_category") or []:
for cat in mc.get("categories") or []:
top = cat.get("top_skill")
if not top:
continue
out.append(
{
"main_category_name": mc.get("main_category_name"),
"category_name": cat.get("category_name"),
"skill_id": top.get("skill_id"),
"skill_name": top.get("skill_name"),
"score": top.get("score") or top.get("weight"),
}
)
if len(out) >= limit:
return out
return out
def _parse_id_list(raw: Any, *, max_count: int = 120) -> List[int]:
if not raw:
return []
if not isinstance(raw, list):
raise HTTPException(status_code=400, detail="ID-Listen müssen Arrays sein")
out: List[int] = []
for item in raw:
try:
n = int(item)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Ungültige ID in Liste") from None
if n > 0 and n not in out:
out.append(n)
if len(out) >= max_count:
break
return out
def _load_planning_corpus(cur, tenant: TenantContext) -> Dict[str, Any]:
return compute_planning_corpus_by_type(
cur,
profile_id=tenant.profile_id,
role=tenant.global_role,
effective_club_id=tenant.effective_club_id,
)
def _enrich_profile_club_best(
profile: Dict[str, Any],
ref_by_skill: Dict[int, Dict[str, Any]],
artifact_type: Optional[str] = None,
artifact_id: Optional[int] = None,
) -> None:
"""Hängt Vereins-Referenz-Artefakt an Fähigkeiten an (wenn nicht selbst Spitze)."""
if not profile or not ref_by_skill:
return
def attach(sk: Optional[Dict[str, Any]]) -> None:
if not sk or sk.get("skill_id") is None:
return
sid = int(sk["skill_id"])
ref = ref_by_skill.get(sid)
if not ref:
return
if (
artifact_type
and artifact_id is not None
and ref.get("artifact_type") == artifact_type
and int(ref.get("artifact_id") or 0) == int(artifact_id)
):
return
w = float(sk.get("weight") or 0)
if w < float(ref.get("weight") or 0) - 0.01:
sk["club_best"] = ref
for sk in profile.get("skills") or []:
attach(sk)
for mc in profile.get("by_main_category") or []:
for cat in mc.get("categories") or []:
attach(cat.get("top_skill"))
def _empty_profile() -> Dict[str, Any]:
return compute_skill_profile([], {})
def _summarize_framework_program(
cur,
framework_id: int,
ref_max: Dict[int, float],
ref_by_skill: Dict[int, Dict[str, Any]],
) -> Dict[str, Any]:
cur.execute(
"""
SELECT tu.id
FROM training_framework_slots s
INNER JOIN training_units tu ON tu.framework_slot_id = s.id
WHERE s.framework_program_id = %s
""",
(int(framework_id),),
)
occ: List[ExerciseOccurrence] = []
for u in cur.fetchall():
occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
prof = (
profile_for_occurrences(cur, occ, reference_max_by_skill=ref_max)
if occ
else _empty_profile()
)
_enrich_profile_club_best(prof, ref_by_skill, "framework_program", int(framework_id))
return compact_profile_summary(prof, ref_by_skill)
def _summarize_training_module(
cur,
module_id: int,
ref_max: Dict[int, float],
ref_by_skill: Dict[int, Dict[str, Any]],
) -> Dict[str, Any]:
occ = collect_module_exercise_occurrences(cur, int(module_id))
prof = (
profile_for_occurrences(cur, occ, reference_max_by_skill=ref_max)
if occ
else _empty_profile()
)
_enrich_profile_club_best(prof, ref_by_skill, "training_module", int(module_id))
return compact_profile_summary(prof, ref_by_skill)
def _merge_batch_summaries(
cur,
*,
bundle: Dict[str, Any],
allowed_fp: List[int],
allowed_mod: List[int],
) -> Dict[str, Dict[str, Any]]:
"""Summaries für angeforderte IDs — Referenz je Planungs-Kontext (Typ getrennt)."""
fw_tc = corpus_for_artifact_type(bundle, "framework_program")
mod_tc = corpus_for_artifact_type(bundle, "training_module")
fw_cached = fw_tc.get("artifact_summaries") or {}
mod_cached = mod_tc.get("artifact_summaries") or {}
out: Dict[str, Dict[str, Any]] = {}
fw_ref_max = fw_tc["max_by_skill"]
fw_ref_by = fw_tc["ref_by_skill"]
missing_fp = [fid for fid in allowed_fp if f"framework_program:{fid}" not in fw_cached]
if missing_fp:
occ_map = batch_framework_occurrences_by_id(cur, missing_fp)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
profiles = batch_compute_profiles(
occ_map, skills_map, reference_max_by_skill=fw_ref_max
)
for fid in missing_fp:
key = f"framework_program:{fid}"
prof = profiles.get(fid) or _empty_profile()
_enrich_profile_club_best(prof, fw_ref_by, "framework_program", fid)
out[key] = compact_profile_summary(prof, fw_ref_by)
mod_ref_max = mod_tc["max_by_skill"]
mod_ref_by = mod_tc["ref_by_skill"]
missing_mod = [mid for mid in allowed_mod if f"training_module:{mid}" not in mod_cached]
if missing_mod:
occ_map = batch_module_occurrences_by_id(cur, missing_mod)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
profiles = batch_compute_profiles(
occ_map, skills_map, reference_max_by_skill=mod_ref_max
)
for mid in missing_mod:
key = f"training_module:{mid}"
prof = profiles.get(mid) or _empty_profile()
_enrich_profile_club_best(prof, mod_ref_by, "training_module", mid)
out[key] = compact_profile_summary(prof, mod_ref_by)
for fid in allowed_fp:
key = f"framework_program:{fid}"
if key in fw_cached:
out[key] = fw_cached[key]
elif key not in out:
out[key] = _summarize_framework_program(cur, fid, fw_ref_max, fw_ref_by)
for mid in allowed_mod:
key = f"training_module:{mid}"
if key in mod_cached:
out[key] = mod_cached[key]
elif key not in out:
out[key] = _summarize_training_module(cur, mid, mod_ref_max, mod_ref_by)
return out

985
backend/skill_scoring.py Normal file
View File

@ -0,0 +1,985 @@
"""
Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert).
Aggregiert exercise_skills über alle Übungen eines Artefakts mit Gewichten aus:
geplanter Dauer, Vorkommen, Intensität (Nutzeneinschätzung) und Stufen-Spanne (von/bis).
is_primary wird bewusst nicht genutzt (perspektivabhängig). development_contribution ist
in der UI nicht gepflegt und wird ignoriert.
"""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
DEFAULT_ITEM_MINUTES = 8
GRAPH_DEFAULT_ITEM_MINUTES = 10
_INTENSITY_MULT = {
"niedrig": 0.85,
"low": 0.85,
"mittel": 1.0,
"medium": 1.0,
"hoch": 1.2,
"high": 1.2,
}
# Synchron zu backend/routers/exercises.py _EXERCISE_SKILL_LEVEL_RANK_SQL / skillLevels.js
_LEVEL_RANK = {
"basis": 1,
"grundlagen": 2,
"aufbau": 3,
"fortgeschritten": 4,
"optimierung": 5,
"einsteiger": 1,
"experte": 5,
"1": 1,
"2": 2,
"3": 3,
"4": 4,
"5": 5,
}
def _level_rank(value: Optional[str]) -> Optional[int]:
if value is None:
return None
key = str(value).strip().lower()
if not key:
return None
rank = _LEVEL_RANK.get(key)
return rank if rank is not None else None
def _level_range_multiplier(
required_level: Optional[str] = None,
target_level: Optional[str] = None,
) -> float:
"""
Stufen-Spanne (von/bis): breitere und höhere Entwicklungsstufen etwas höheres Gewicht.
Fehlen beide Angaben: neutral (1.0).
"""
rr = _level_rank(required_level)
rt = _level_rank(target_level)
if rr is None and rt is None:
return 1.0
if rr is None:
rr = rt
if rt is None:
rt = rr
if rr > rt:
rr, rt = rt, rr
span = max(1, min(5, rt - rr + 1))
midpoint = (rr + rt) / 2.0
span_mult = 0.92 + 0.04 * span
depth_mult = 0.95 + 0.025 * midpoint
return span_mult * depth_mult
@dataclass(frozen=True)
class ExerciseOccurrence:
exercise_id: int
planned_duration_min: Optional[int] = None
"""Optional label for UI (e.g. slot title)."""
context_label: Optional[str] = None
def _item_base_minutes(planned: Optional[int], default: int = DEFAULT_ITEM_MINUTES) -> float:
if planned is not None:
try:
m = int(planned)
if m > 0:
return float(m)
except (TypeError, ValueError):
pass
return float(default)
def _skill_link_multiplier(
*,
intensity: Optional[str] = None,
required_level: Optional[str] = None,
target_level: Optional[str] = None,
) -> float:
mult = 1.0
if intensity:
key = str(intensity).strip().lower()
mult *= _INTENSITY_MULT.get(key, 1.0)
mult *= _level_range_multiplier(required_level, target_level)
return mult
def _round2(val: float) -> float:
return round(val, 2)
def _build_by_main_category(skills_out: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Hierarchie Hauptkategorie → Unterkategorie, je Top-Fähigkeit nach absolutem Gewicht."""
main_map: Dict[int, Dict[str, Any]] = {}
for sk in skills_out:
mc_id = int(sk.get("main_category_id") or 0)
mc_name = (sk.get("main_category_name") or "").strip() or ""
cat_id = int(sk.get("category_id") or 0)
cat_name = (sk.get("category_name") or sk.get("category") or "").strip() or ""
if mc_id not in main_map:
main_map[mc_id] = {
"main_category_id": mc_id if mc_id else None,
"main_category_name": mc_name,
"weight": 0.0,
"categories": {},
}
main = main_map[mc_id]
main["weight"] += float(sk.get("weight") or 0)
if cat_id not in main["categories"]:
main["categories"][cat_id] = {
"category_id": cat_id if cat_id else None,
"category_name": cat_name,
"weight": 0.0,
"skills": [],
}
cat = main["categories"][cat_id]
cat["weight"] += float(sk.get("weight") or 0)
cat["skills"].append(sk)
result: List[Dict[str, Any]] = []
for mc in sorted(main_map.values(), key=lambda x: (-x["weight"], x.get("main_category_name") or "")):
cats_out: List[Dict[str, Any]] = []
for cat in sorted(
mc["categories"].values(),
key=lambda x: (-x["weight"], x.get("category_name") or ""),
):
cat_skills = sorted(
cat["skills"],
key=lambda x: (-float(x.get("weight") or 0), x.get("skill_name") or ""),
)
top = cat_skills[0] if cat_skills else None
cats_out.append(
{
"category_id": cat["category_id"],
"category_name": cat["category_name"],
"weight": _round2(cat["weight"]),
"skills_count": len(cat_skills),
"top_skill": top,
}
)
result.append(
{
"main_category_id": mc["main_category_id"],
"main_category_name": mc["main_category_name"],
"weight": _round2(mc["weight"]),
"categories": cats_out,
}
)
return result
def _club_universal_percent(
weight: float,
corpus_ref: float,
) -> tuple[Optional[float], bool]:
"""
Anteil am Vereins-Maximum (max. 100 %).
effective_ref = max(Korpus-Max, eigenes Gewicht) verhindert Werte >100 %,
wenn das Artefakt stärker ist als der bisherige Vereins-Vergleich (z. B. official).
"""
w = float(weight or 0)
ref = float(corpus_ref or 0)
if w <= 0:
return None, False
effective_ref = max(ref, w)
pct = min(100.0, w / effective_ref * 100.0)
is_best = ref <= 0 or w >= ref - 0.01
return _round2(pct), is_best
def _apply_reference_universal_percent(
skills_out: List[Dict[str, Any]],
reference_max_by_skill: Optional[Dict[int, float]] = None,
) -> None:
"""
Stärke relativ zum Vereins-Maximum je Fähigkeit (gecappt auf 100 %).
"""
if not reference_max_by_skill:
for sk in skills_out:
sk["universal_percent"] = None
sk["is_club_best_for_skill"] = False
return
for sk in skills_out:
sid = int(sk["skill_id"])
ref = float(reference_max_by_skill.get(sid) or 0)
w = float(sk.get("weight") or 0)
pct, is_best = _club_universal_percent(w, ref)
sk["universal_percent"] = pct
sk["is_club_best_for_skill"] = is_best
def compute_skill_profile(
occurrences: Sequence[ExerciseOccurrence],
skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]],
*,
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
reference_max_by_skill: Optional[Dict[int, float]] = None,
) -> Dict[str, Any]:
"""
Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills.
"""
exercise_meta: Dict[int, Dict[str, Any]] = defaultdict(
lambda: {"occurrence_count": 0, "minutes": 0.0, "context_labels": []}
)
total_occurrences = 0
for occ in occurrences or []:
eid = int(occ.exercise_id)
mins = _item_base_minutes(occ.planned_duration_min, default_item_minutes)
exercise_meta[eid]["occurrence_count"] += 1
exercise_meta[eid]["minutes"] += mins
total_occurrences += 1
if occ.context_label and occ.context_label not in exercise_meta[eid]["context_labels"]:
exercise_meta[eid]["context_labels"].append(occ.context_label)
skill_acc: Dict[int, Dict[str, Any]] = {}
total_weight = 0.0
exercises_with_skills: set[int] = set()
for eid, meta in exercise_meta.items():
links = skill_rows_by_exercise.get(eid) or []
if not links:
continue
exercises_with_skills.add(eid)
occ_count = meta["occurrence_count"]
minutes_per_occ = meta["minutes"] / occ_count if occ_count else float(default_item_minutes)
for link in links:
sid = link.get("skill_id")
if sid is None:
continue
sid = int(sid)
link_mult = _skill_link_multiplier(
intensity=link.get("intensity"),
required_level=link.get("required_level"),
target_level=link.get("target_level"),
)
contribution = minutes_per_occ * occ_count * link_mult
if contribution <= 0:
continue
if sid not in skill_acc:
skill_acc[sid] = {
"skill_id": sid,
"skill_name": link.get("skill_name") or f"Fähigkeit #{sid}",
"category": link.get("category_name") or link.get("category"),
"category_id": link.get("category_id"),
"category_name": link.get("category_name") or link.get("category"),
"main_category_id": link.get("main_category_id"),
"main_category_name": link.get("main_category_name"),
"focus_areas": link.get("focus_areas"),
"weight": 0.0,
"occurrence_count": 0,
"exercises": {},
}
acc = skill_acc[sid]
acc["weight"] += contribution
acc["occurrence_count"] += occ_count
ex_key = str(eid)
if ex_key not in acc["exercises"]:
acc["exercises"][ex_key] = {
"exercise_id": eid,
"title": link.get("exercise_title") or f"Übung #{eid}",
"weight": 0.0,
"occurrence_count": occ_count,
}
acc["exercises"][ex_key]["weight"] += contribution
total_weight += contribution
skills_out: List[Dict[str, Any]] = []
for sid, acc in skill_acc.items():
share = (acc["weight"] / total_weight * 100.0) if total_weight > 0 else 0.0
ex_list = sorted(
acc["exercises"].values(),
key=lambda x: (-x["weight"], x.get("title") or ""),
)[:8]
for ex in ex_list:
ex["weight"] = _round2(ex["weight"])
if total_weight > 0:
ex["share_percent"] = _round2(ex["weight"] / total_weight * 100.0)
else:
ex["share_percent"] = 0.0
skills_out.append(
{
"skill_id": sid,
"skill_name": acc["skill_name"],
"category": acc.get("category_name") or acc.get("category"),
"category_id": acc.get("category_id"),
"category_name": acc.get("category_name") or acc.get("category"),
"main_category_id": acc.get("main_category_id"),
"main_category_name": acc.get("main_category_name"),
"focus_areas": acc.get("focus_areas"),
"weight": _round2(acc["weight"]),
"score": _round2(acc["weight"]),
"artifact_share_percent": _round2(share),
"share_percent": _round2(share),
"occurrence_count": acc["occurrence_count"],
"top_exercises": ex_list,
}
)
skills_out.sort(key=lambda x: (-x["weight"], x.get("skill_name") or ""))
_apply_reference_universal_percent(skills_out, reference_max_by_skill)
by_main_category = _build_by_main_category(skills_out)
for mc in by_main_category:
for cat in mc.get("categories") or []:
top = cat.get("top_skill")
if top and reference_max_by_skill:
sid = int(top["skill_id"])
ref = float(reference_max_by_skill.get(sid) or 0)
pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref)
top["universal_percent"] = pct
top["is_club_best_for_skill"] = is_best
unique_exercises = len(exercise_meta)
return {
"computed_at": datetime.now(timezone.utc).isoformat(),
"scoring_version": "1.2",
"score_unit": "weighted_minutes",
"score_unit_label": "Trainingsgewicht (gewichtete Minuten, über Programme vergleichbar)",
"total_weight": _round2(total_weight),
"total_score": _round2(total_weight),
"exercise_occurrence_count": total_occurrences,
"distinct_exercise_count": unique_exercises,
"exercises_with_skills_count": len(exercises_with_skills),
"skills": skills_out,
"by_main_category": by_main_category,
"has_reference_scale": bool(reference_max_by_skill),
}
def fetch_exercise_skills_bulk(
cur, exercise_ids: Iterable[int]
) -> Dict[int, List[Dict[str, Any]]]:
ids = sorted({int(x) for x in exercise_ids if x})
if not ids:
return {}
ph = ",".join(["%s"] * len(ids))
cur.execute(
f"""
SELECT es.exercise_id, es.skill_id, es.is_primary, es.intensity,
es.development_contribution, es.required_level, es.target_level,
s.name AS skill_name, s.category,
sc.id AS category_id, sc.name AS category_name,
mc.id AS main_category_id, mc.name AS main_category_name,
s.focus_areas,
e.title AS exercise_title
FROM exercise_skills es
JOIN skills s ON s.id = es.skill_id
LEFT JOIN skill_categories sc ON sc.id = s.category_id
LEFT JOIN skill_main_categories mc ON mc.id = COALESCE(s.main_category_id, sc.main_category_id)
JOIN exercises e ON e.id = es.exercise_id
WHERE es.exercise_id IN ({ph})
AND (s.status = 'active' OR s.status IS NULL)
ORDER BY es.exercise_id, s.name, es.skill_id
""",
ids,
)
out: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
for row in cur.fetchall():
d = dict(row)
eid = int(d["exercise_id"])
fa = d.get("focus_areas")
if fa is not None and not isinstance(fa, list):
try:
import json
fa = json.loads(fa) if isinstance(fa, str) else fa
except Exception:
fa = []
d["focus_areas"] = fa if isinstance(fa, list) else []
out[eid].append(d)
return dict(out)
def collect_unit_exercise_occurrences(cur, unit_id: int) -> List[ExerciseOccurrence]:
cur.execute(
"""
SELECT tusi.exercise_id, tusi.planned_duration_min
FROM training_unit_section_items tusi
INNER JOIN training_unit_sections tus ON tus.id = tusi.section_id
WHERE tus.training_unit_id = %s
AND tusi.item_type = 'exercise'
AND tusi.exercise_id IS NOT NULL
ORDER BY tus.order_index, tusi.order_index
""",
(int(unit_id),),
)
return [
ExerciseOccurrence(
exercise_id=int(r["exercise_id"]),
planned_duration_min=r.get("planned_duration_min"),
)
for r in cur.fetchall()
]
def collect_module_exercise_occurrences(cur, module_id: int) -> List[ExerciseOccurrence]:
cur.execute(
"""
SELECT exercise_id, planned_duration_min
FROM training_module_items
WHERE module_id = %s
AND item_type = 'exercise'
AND exercise_id IS NOT NULL
ORDER BY order_index
""",
(int(module_id),),
)
return [
ExerciseOccurrence(
exercise_id=int(r["exercise_id"]),
planned_duration_min=r.get("planned_duration_min"),
)
for r in cur.fetchall()
]
def collect_progression_graph_exercise_occurrences(cur, graph_id: int) -> List[ExerciseOccurrence]:
"""Jedes Vorkommen als from- oder to-Endpunkt einer Kante zählt (ohne Dauer → Default)."""
cur.execute(
"""
SELECT from_exercise_id AS exercise_id FROM exercise_progression_edges WHERE graph_id = %s
UNION ALL
SELECT to_exercise_id AS exercise_id FROM exercise_progression_edges WHERE graph_id = %s
""",
(int(graph_id), int(graph_id)),
)
return [
ExerciseOccurrence(
exercise_id=int(r["exercise_id"]),
planned_duration_min=None,
context_label=None,
)
for r in cur.fetchall()
]
def profile_for_occurrences(
cur,
occurrences: Sequence[ExerciseOccurrence],
*,
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
reference_max_by_skill: Optional[Dict[int, float]] = None,
) -> Dict[str, Any]:
eids = [o.exercise_id for o in occurrences]
skills_map = fetch_exercise_skills_bulk(cur, eids)
return compute_skill_profile(
occurrences,
skills_map,
default_item_minutes=default_item_minutes,
reference_max_by_skill=reference_max_by_skill,
)
def merge_skill_weights_into_max(
target: Dict[int, float],
profile: Dict[str, Any],
) -> None:
for sk in profile.get("skills") or []:
sid = int(sk["skill_id"])
w = float(sk.get("weight") or 0)
if w > target.get(sid, 0.0):
target[sid] = w
def merge_skill_weights_with_reference(
max_by_skill: Dict[int, float],
ref_by_skill: Dict[int, Dict[str, Any]],
profile: Dict[str, Any],
*,
artifact_type: str,
artifact_id: int,
artifact_title: Optional[str] = None,
) -> None:
"""Aktualisiert Vereins-Maximum je Fähigkeit inkl. Quell-Artefakt."""
for sk in profile.get("skills") or []:
sid = int(sk["skill_id"])
w = float(sk.get("weight") or 0)
if w <= 0:
continue
if w > max_by_skill.get(sid, 0.0):
max_by_skill[sid] = w
ref_by_skill[sid] = {
"artifact_type": artifact_type,
"artifact_id": int(artifact_id),
"artifact_title": (artifact_title or "").strip() or None,
"weight": _round2(w),
}
def top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]:
"""Top-Fähigkeit je Unterkategorie (kompakt für Listen/Discovery)."""
out: List[Dict[str, Any]] = []
for mc in profile.get("by_main_category") or []:
for cat in mc.get("categories") or []:
top = cat.get("top_skill")
if not top:
continue
out.append(
{
"main_category_name": mc.get("main_category_name"),
"category_name": cat.get("category_name"),
"skill_id": top.get("skill_id"),
"skill_name": top.get("skill_name"),
"score": top.get("score") or top.get("weight"),
"weight": top.get("weight"),
"universal_percent": top.get("universal_percent"),
"is_club_best_for_skill": top.get("is_club_best_for_skill"),
}
)
if len(out) >= limit:
return out
return out
def _apply_reference_to_profile(
profile: Dict[str, Any],
reference_max_by_skill: Optional[Dict[int, float]],
) -> None:
_apply_reference_universal_percent(profile.get("skills") or [], reference_max_by_skill)
profile["by_main_category"] = _build_by_main_category(profile.get("skills") or [])
for mc in profile.get("by_main_category") or []:
for cat in mc.get("categories") or []:
top = cat.get("top_skill")
if top and reference_max_by_skill:
sid = int(top["skill_id"])
ref = float(reference_max_by_skill.get(sid) or 0)
pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref)
top["universal_percent"] = pct
top["is_club_best_for_skill"] = is_best
profile["has_reference_scale"] = bool(reference_max_by_skill)
def compact_profile_summary(
profile: Dict[str, Any],
ref_by_skill: Optional[Dict[int, Dict[str, Any]]] = None,
*,
skills_limit: int = 0,
category_limit: int = 48,
) -> Dict[str, Any]:
"""Leichtgewichtiges Profil für Listen — ohne Übungsdetails. skills_limit=0 → alle Fähigkeiten."""
skills_out: List[Dict[str, Any]] = []
for s in profile.get("skills") or []:
sid = int(s["skill_id"])
w = float(s.get("weight") or 0)
entry: Dict[str, Any] = {
"skill_id": sid,
"skill_name": s.get("skill_name"),
"category_name": s.get("category_name") or s.get("category"),
"main_category_name": s.get("main_category_name"),
"weight": s.get("weight"),
"score": s.get("score") or s.get("weight"),
"universal_percent": s.get("universal_percent"),
}
ref = (ref_by_skill or {}).get(sid)
if ref and w < float(ref.get("weight") or 0) - 0.01:
entry["club_best"] = ref
if s.get("is_club_best_for_skill"):
entry["is_club_best_for_skill"] = True
skills_out.append(entry)
if skills_limit > 0 and len(skills_out) >= skills_limit:
break
return {
"total_score": profile.get("total_score"),
"total_weight": profile.get("total_weight"),
"exercise_occurrence_count": profile.get("exercise_occurrence_count"),
"skills_count": len(profile.get("skills") or []),
"top_by_category": top_categories_summary(profile, limit=category_limit),
"skills": skills_out,
}
def batch_framework_occurrences_by_id(
cur, framework_ids: Sequence[int]
) -> Dict[int, List[ExerciseOccurrence]]:
ids = sorted({int(x) for x in framework_ids if x})
if not ids:
return {}
ph = ",".join(["%s"] * len(ids))
cur.execute(
f"""
SELECT s.framework_program_id,
COALESCE(NULLIF(TRIM(s.title), ''), 'Session ' || (s.sort_order + 1)::text) AS slot_label,
tusi.exercise_id,
tusi.planned_duration_min
FROM training_framework_slots s
INNER JOIN training_units tu ON tu.framework_slot_id = s.id
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 s.framework_program_id IN ({ph})
AND tusi.item_type = 'exercise'
AND tusi.exercise_id IS NOT NULL
ORDER BY s.framework_program_id, s.sort_order, tus.order_index, tusi.order_index
""",
ids,
)
out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list)
for row in cur.fetchall():
fid = int(row["framework_program_id"])
out[fid].append(
ExerciseOccurrence(
exercise_id=int(row["exercise_id"]),
planned_duration_min=row.get("planned_duration_min"),
context_label=row.get("slot_label"),
)
)
return dict(out)
def batch_module_occurrences_by_id(
cur, module_ids: Sequence[int]
) -> Dict[int, List[ExerciseOccurrence]]:
ids = sorted({int(x) for x in module_ids if x})
if not ids:
return {}
ph = ",".join(["%s"] * len(ids))
cur.execute(
f"""
SELECT module_id, exercise_id, planned_duration_min
FROM training_module_items
WHERE module_id IN ({ph})
AND item_type = 'exercise'
AND exercise_id IS NOT NULL
ORDER BY module_id, order_index
""",
ids,
)
out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list)
for row in cur.fetchall():
mid = int(row["module_id"])
out[mid].append(
ExerciseOccurrence(
exercise_id=int(row["exercise_id"]),
planned_duration_min=row.get("planned_duration_min"),
)
)
return dict(out)
def batch_compute_profiles(
occ_by_artifact: Dict[int, List[ExerciseOccurrence]],
skills_map: Dict[int, List[Dict[str, Any]]],
*,
reference_max_by_skill: Optional[Dict[int, float]] = None,
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
) -> Dict[int, Dict[str, Any]]:
return {
aid: compute_skill_profile(
occ,
skills_map,
default_item_minutes=default_item_minutes,
reference_max_by_skill=reference_max_by_skill,
)
for aid, occ in occ_by_artifact.items()
}
def _empty_type_corpus() -> Dict[str, Any]:
return {
"max_by_skill": {},
"ref_by_skill": {},
"artifact_count": 0,
"artifact_summaries": {},
}
def corpus_for_artifact_type(
bundle: Dict[str, Any],
artifact_type: str,
) -> Dict[str, Any]:
by_type = bundle.get("by_type") or {}
return by_type.get(artifact_type) or _empty_type_corpus()
def _scan_artifact_type_corpus(
cur,
*,
artifact_type: str,
profile_id: int,
role: Optional[str],
effective_club_id: Optional[int],
include_artifact_summaries: bool,
) -> Dict[str, Any]:
"""Referenz je Fähigkeit nur innerhalb eines Planungs-Kontexts (ein Artefakttyp, sichtbare Bibliothek)."""
from tenant_context import library_content_visibility_sql
max_by_skill: Dict[int, float] = {}
ref_by_skill: Dict[int, Dict[str, Any]] = {}
artifact_count = 0
raw_profiles: Dict[str, Dict[str, Any]] = {}
def ingest(aid: int, title: Optional[str], prof: Dict[str, Any]) -> None:
nonlocal artifact_count
if not prof.get("skills"):
return
artifact_count += 1
merge_skill_weights_with_reference(
max_by_skill,
ref_by_skill,
prof,
artifact_type=artifact_type,
artifact_id=aid,
artifact_title=title,
)
if include_artifact_summaries:
raw_profiles[f"{artifact_type}:{aid}"] = prof
if artifact_type == "framework_program":
vis_clause, vis_params = library_content_visibility_sql(
alias="fp",
profile_id=profile_id,
role=role or "",
effective_club_id=effective_club_id,
)
cur.execute(
f"""
SELECT fp.id, fp.title
FROM training_framework_programs fp
WHERE ({vis_clause})
ORDER BY fp.updated_at DESC NULLS LAST
""",
vis_params,
)
rows = cur.fetchall()
if rows:
ids = [int(r["id"]) for r in rows]
titles = {int(r["id"]): r.get("title") for r in rows}
occ_map = batch_framework_occurrences_by_id(cur, ids)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
profiles = batch_compute_profiles(occ_map, skills_map)
for aid, prof in profiles.items():
ingest(aid, titles.get(aid), prof)
elif artifact_type == "training_module":
vis_clause, vis_params = library_content_visibility_sql(
alias="m",
profile_id=profile_id,
role=role or "",
effective_club_id=effective_club_id,
)
cur.execute(
f"""
SELECT m.id, m.title
FROM training_modules m
WHERE ({vis_clause})
ORDER BY m.updated_at DESC NULLS LAST
""",
vis_params,
)
rows = cur.fetchall()
if rows:
ids = [int(r["id"]) for r in rows]
titles = {int(r["id"]): r.get("title") for r in rows}
occ_map = batch_module_occurrences_by_id(cur, ids)
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
profiles = batch_compute_profiles(occ_map, skills_map)
for aid, prof in profiles.items():
ingest(aid, titles.get(aid), prof)
elif artifact_type == "progression_graph":
vis_clause, vis_params = library_content_visibility_sql(
alias="g",
profile_id=profile_id,
role=role or "",
effective_club_id=effective_club_id,
)
cur.execute(
f"""
SELECT g.id, g.name
FROM exercise_progression_graphs g
WHERE ({vis_clause})
ORDER BY g.updated_at DESC NULLS LAST
""",
vis_params,
)
for row in cur.fetchall():
gid = int(row["id"])
occ = collect_progression_graph_exercise_occurrences(cur, gid)
if not occ:
continue
prof = profile_for_occurrences(
cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
)
ingest(gid, row.get("name"), prof)
artifact_summaries: Dict[str, Dict[str, Any]] = {}
if include_artifact_summaries and raw_profiles:
for key, prof in raw_profiles.items():
_apply_reference_to_profile(prof, max_by_skill)
artifact_summaries[key] = compact_profile_summary(prof, ref_by_skill)
return {
"max_by_skill": max_by_skill,
"ref_by_skill": ref_by_skill,
"artifact_count": artifact_count,
"artifact_summaries": artifact_summaries,
}
def compute_planning_corpus_by_type(
cur,
*,
profile_id: int,
role: Optional[str],
effective_club_id: Optional[int],
include_artifact_summaries: bool = False,
) -> Dict[str, Any]:
"""
Referenz je Fähigkeit getrennt nach Planungs-Kontext:
Rahmenprogramme, Trainingsmodule und Regressionspfade jeweils für sich,
jeweils über die sichtbare Bibliothek (library_content_visibility_sql).
"""
by_type = {
"framework_program": _scan_artifact_type_corpus(
cur,
artifact_type="framework_program",
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
include_artifact_summaries=include_artifact_summaries,
),
"training_module": _scan_artifact_type_corpus(
cur,
artifact_type="training_module",
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
include_artifact_summaries=include_artifact_summaries,
),
"progression_graph": _scan_artifact_type_corpus(
cur,
artifact_type="progression_graph",
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
include_artifact_summaries=include_artifact_summaries,
),
}
return {
"effective_club_id": effective_club_id,
"by_type": by_type,
}
def compute_club_corpus_reference(
cur,
*,
profile_id: int,
effective_club_id: Optional[int],
include_artifact_summaries: bool = False,
role: Optional[str] = None,
) -> Dict[str, Any]:
"""Legacy-Hülle — merged summaries über alle Typen (vermeiden für neue Aufrufer)."""
bundle = compute_planning_corpus_by_type(
cur,
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
include_artifact_summaries=include_artifact_summaries,
)
tc = corpus_for_artifact_type(bundle, "framework_program")
merged_summaries: Dict[str, Dict[str, Any]] = {}
for t in ("framework_program", "training_module", "progression_graph"):
merged_summaries.update((bundle["by_type"][t].get("artifact_summaries") or {}))
return {
"club_id": effective_club_id,
"max_by_skill": tc["max_by_skill"],
"ref_by_skill": tc["ref_by_skill"],
"artifact_count": sum(
(bundle["by_type"][t].get("artifact_count") or 0)
for t in bundle["by_type"]
),
"artifact_summaries": merged_summaries,
}
_ARTIFACT_TYPE_LABELS = {
"framework_program": "Rahmenprogrammen",
"training_module": "Trainingsmodulen",
"progression_graph": "Regressionspfaden",
}
def reference_scale_meta(
type_corpus: Dict[str, Any],
artifact_type: str,
*,
effective_club_id: Optional[int] = None,
) -> Dict[str, Any]:
label = _ARTIFACT_TYPE_LABELS.get(artifact_type, artifact_type)
return {
"scope": "planning_peer",
"artifact_type": artifact_type,
"effective_club_id": effective_club_id,
"skills_in_corpus": len(type_corpus.get("max_by_skill") or {}),
"artifacts_scanned": type_corpus.get("artifact_count") or 0,
"description": (
f"Prozent = Anteil am stärksten sichtbaren Eintrag unter {label} je Fähigkeit "
f"(nicht gemischt mit anderen Planungs-Artefakttypen)"
),
}
def compute_corpus_skill_max_weights(
cur,
*,
profile_id: int,
role: Optional[str],
effective_club_id: Optional[int],
limit_per_type: int = 50,
artifact_type: str = "framework_program",
) -> Dict[int, float]:
"""Referenz je Fähigkeit innerhalb eines Planungs-Kontexts (Legacy-Hülle)."""
del limit_per_type
bundle = compute_planning_corpus_by_type(
cur,
profile_id=profile_id,
role=role,
effective_club_id=effective_club_id,
)
return corpus_for_artifact_type(bundle, artifact_type)["max_by_skill"]
def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:
"""Überlappung eines Profils mit gewünschten Fähigkeiten (für Vorschläge)."""
wanted = {int(x) for x in skill_ids if x is not None}
if not wanted:
return {
"match_weight": 0.0,
"match_score": 0.0,
"match_percent": 0.0,
"artifact_focus_percent": 0.0,
"matched_skill_ids": [],
"matched_skills": [],
}
matched = []
match_weight = 0.0
total = float(profile.get("total_weight") or 0)
for sk in profile.get("skills") or []:
sid = int(sk["skill_id"])
if sid in wanted:
matched.append(sk)
match_weight += float(sk.get("weight") or 0)
artifact_focus = (match_weight / total * 100.0) if total > 0 else 0.0
return {
"match_weight": _round2(match_weight),
"match_score": _round2(match_weight),
"match_percent": _round2(artifact_focus),
"artifact_focus_percent": _round2(artifact_focus),
"matched_skill_ids": [int(m["skill_id"]) for m in matched],
"matched_skills": matched,
}

View File

@ -107,6 +107,33 @@ def library_content_visibility_sql(
return "(" + " OR ".join(parts) + ")", params
def club_library_visibility_sql(
*,
alias: str,
profile_id: int,
effective_club_id: Optional[int],
) -> tuple[str, List[Any]]:
"""
Nur Inhalte des aktiven Vereins (visibility=club, club_id=active).
Für Skill-Vergleiche im Vereinskontext ohne official/private anderer Mandanten.
"""
if effective_club_id is None:
return "(1=0)", []
return (
f"""(
{alias}.visibility = 'club'
AND {alias}.club_id = %s
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s
AND cm.club_id = {alias}.club_id
AND cm.status = 'active'
)
)""",
[effective_club_id, profile_id],
)
@dataclass
class TenantContext:
profile_id: int

View File

@ -0,0 +1,125 @@
"""Unit-Tests für gewichtetes Fähigkeiten-Scoring (Phase 3)."""
from skill_scoring import (
ExerciseOccurrence,
compute_skill_profile,
match_score_for_skill_ids,
_club_universal_percent,
_level_range_multiplier,
_skill_link_multiplier,
)
def test_skill_link_multiplier_intensity_and_levels():
assert _skill_link_multiplier(intensity="hoch") == 1.2
assert _skill_link_multiplier(intensity="niedrig") == 0.85
wide = _skill_link_multiplier(
intensity="mittel",
required_level="basis",
target_level="optimierung",
)
narrow = _skill_link_multiplier(
intensity="mittel",
required_level="grundlagen",
target_level="grundlagen",
)
assert wide > narrow
def test_level_range_multiplier_span():
assert _level_range_multiplier(None, None) == 1.0
assert _level_range_multiplier("aufbau", "fortgeschritten") > _level_range_multiplier("basis", "basis")
def test_compute_skill_profile_aggregates_weights():
occurrences = [
ExerciseOccurrence(exercise_id=1, planned_duration_min=60),
ExerciseOccurrence(exercise_id=1, planned_duration_min=30),
]
skills_map = {
1: [
{
"skill_id": 10,
"skill_name": "Distanz",
"category": "kihon",
"category_id": 1,
"category_name": "kihon",
"main_category_id": 100,
"main_category_name": "Technik",
"intensity": "hoch",
"required_level": "grundlagen",
"target_level": "aufbau",
"exercise_title": "Übung A",
},
{
"skill_id": 11,
"skill_name": "Balance",
"category": "kihon",
"category_id": 1,
"category_name": "kihon",
"main_category_id": 100,
"main_category_name": "Technik",
"intensity": "niedrig",
"required_level": "basis",
"target_level": "basis",
"exercise_title": "Übung A",
},
],
}
profile = compute_skill_profile(occurrences, skills_map)
assert profile["scoring_version"] == "1.2"
assert profile["exercise_occurrence_count"] == 2
assert profile["distinct_exercise_count"] == 1
assert len(profile["skills"]) == 2
assert profile["skills"][0]["skill_id"] == 10
assert profile["total_weight"] > profile["skills"][1]["weight"]
assert abs(sum(s["share_percent"] for s in profile["skills"]) - 100.0) < 0.1
assert len(profile["by_main_category"]) == 1
assert profile["by_main_category"][0]["categories"][0]["top_skill"]["skill_id"] == 10
def test_universal_percent_against_corpus_max():
occurrences = [ExerciseOccurrence(exercise_id=1, planned_duration_min=50)]
skills_map = {
1: [
{
"skill_id": 10,
"skill_name": "Koordination",
"category_name": "Koordination",
"category_id": 2,
"main_category_id": 200,
"main_category_name": "Körper",
},
],
}
profile = compute_skill_profile(
occurrences,
skills_map,
reference_max_by_skill={10: 100.0},
)
assert profile["has_reference_scale"] is True
assert profile["skills"][0]["universal_percent"] == 50.0
assert profile["skills"][0]["is_club_best_for_skill"] is False
def test_club_universal_percent_capped_at_100():
pct, is_best = _club_universal_percent(150.0, 100.0)
assert pct == 100.0
assert is_best is True
pct2, _ = _club_universal_percent(72.6, 100.0)
assert pct2 == 72.6
def test_match_score_for_skill_ids():
profile = {
"total_weight": 100.0,
"skills": [
{"skill_id": 1, "skill_name": "A", "weight": 40.0},
{"skill_id": 2, "skill_name": "B", "weight": 60.0},
],
}
m = match_score_for_skill_ids(profile, [1])
assert m["match_weight"] == 40.0
assert m["match_score"] == 40.0
assert m["match_percent"] == 40.0
assert m["artifact_focus_percent"] == 40.0
assert m["matched_skill_ids"] == [1]

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.150"
APP_VERSION = "0.8.151"
BUILD_DATE = "2026-05-20"
DB_SCHEMA_VERSION = "20260520066"
@ -20,6 +20,7 @@ MODULE_VERSIONS = {
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"groups": "0.1.0",
"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
"methods": "0.1.0",
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
@ -36,6 +37,15 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.151",
"date": "2026-05-20",
"changes": [
"Phase 3 Fähigkeiten-Scoring: skill_scoring.py (Dauer × Vorkommen × Primär/Intensität/Beitrag)",
"GET skill-profile für Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph",
"GET /api/skill-discovery/suggestions nach skill_ids",
],
},
{
"version": "0.8.149",
"date": "2026-05-19",

View File

@ -0,0 +1,40 @@
import { request } from './client.js'
export async function getFrameworkProgramSkillProfile(frameworkId) {
return request(`/api/training-framework-programs/${frameworkId}/skill-profile`)
}
export async function getTrainingModuleSkillProfile(moduleId) {
return request(`/api/training-modules/${moduleId}/skill-profile`)
}
export async function getProgressionGraphSkillProfile(graphId) {
return request(`/api/exercise-progression-graphs/${graphId}/skill-profile`)
}
/**
* @param {number[]} skillIds
* @param {{ types?: string, limit?: number }} opts
*/
export async function getSkillDiscoverySuggestions(skillIds, opts = {}) {
const ids = (skillIds || []).filter((x) => x != null && Number(x) > 0).join(',')
if (!ids) return { skill_ids: [], suggestions: [] }
const params = new URLSearchParams({ skill_ids: ids })
if (opts.types) params.set('types', opts.types)
if (opts.limit != null) params.set('limit', String(opts.limit))
return request(`/api/skill-discovery/suggestions?${params.toString()}`)
}
/**
* Batch-Summaries für Listen (ein Vereins-Corpus-Scan).
* @param {{ frameworkProgramIds?: number[], trainingModuleIds?: number[] }} payload
*/
export async function batchSkillProfileSummaries(payload = {}) {
return request('/api/skill-profiles/batch-summaries', {
method: 'POST',
body: JSON.stringify({
framework_program_ids: payload.frameworkProgramIds || [],
training_module_ids: payload.trainingModuleIds || [],
}),
})
}

View File

@ -2475,6 +2475,474 @@ html.modal-scroll-locked .app-main {
}
}
/* Phase 3: Fähigkeiten-Profil & Planungs-Vorschläge */
.skill-profile {
margin-bottom: 1rem;
padding: 0;
overflow: hidden;
}
.skill-profile--loading,
.skill-profile--error {
padding: 1rem 1.15rem;
}
.skill-profile__status {
margin: 0;
color: var(--text2);
font-size: 0.9rem;
}
.skill-profile__toggle {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
width: 100%;
padding: 0.85rem 1.1rem;
border: none;
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
}
.skill-profile__toggle-title {
font-weight: 700;
color: var(--text1);
}
.skill-profile__toggle-badge {
font-size: 0.78rem;
color: var(--accent-dark);
background: var(--accent-light);
padding: 3px 8px;
border-radius: 999px;
}
.skill-profile__toggle-icon {
margin-left: auto;
color: var(--text3);
}
.skill-profile__body {
padding: 0 1.1rem 1rem;
border-top: 1px solid var(--border);
}
.skill-profile__hint {
margin: 0.65rem 0 0.75rem;
}
.skill-profile__empty {
margin: 0;
color: var(--text2);
font-size: 0.88rem;
line-height: 1.5;
}
.skill-profile__stats {
display: flex;
flex-wrap: wrap;
gap: 12px 18px;
font-size: 0.82rem;
color: var(--text2);
margin-bottom: 0.75rem;
}
.skill-profile__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.skill-profile__list--nested {
margin: 8px 0 0;
padding-left: 8px;
border-left: 2px solid var(--border);
}
.skill-profile__row-head {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.skill-profile__name {
font-weight: 600;
font-size: 0.88rem;
color: var(--text1);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.skill-profile__pct {
font-size: 0.82rem;
font-weight: 700;
color: var(--accent-dark);
flex-shrink: 0;
}
.skill-profile__bar-track {
height: 8px;
border-radius: 4px;
background: var(--surface2);
overflow: hidden;
}
.skill-profile__bar-fill {
height: 100%;
border-radius: 4px;
background: linear-gradient(90deg, var(--accent) 0%, var(--accent-dark) 100%);
min-width: 2px;
}
.skill-profile__meta-hint {
display: block;
font-size: 0.72rem;
color: var(--text3);
margin-top: 3px;
}
.skill-profile__by-category {
display: flex;
flex-direction: column;
gap: 14px;
}
.skill-profile__main-cat {
padding: 10px 12px;
border-radius: 10px;
background: var(--surface2);
border: 1px solid var(--border);
}
.skill-profile__main-cat-title {
margin: 0 0 8px;
font-size: 0.82rem;
font-weight: 700;
color: var(--text2);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.skill-profile__cat-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.skill-profile__cat-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.skill-profile__cat-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text3);
}
.skill-profile__cat-row {
list-style: none;
}
.skill-profile__categories {
margin-top: 0.85rem;
}
.skill-profile__categories-label,
.skill-profile__slots-label {
display: block;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text3);
margin-bottom: 6px;
}
.skill-profile__category-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.skill-profile__category-chip {
font-size: 0.78rem;
padding: 3px 8px;
border-radius: 6px;
background: var(--surface2);
color: var(--text2);
}
.skill-profile__slots {
margin-top: 1rem;
padding-top: 0.85rem;
border-top: 1px solid var(--border);
}
.skill-profile__slot-list {
list-style: none;
margin: 0;
padding: 0;
}
.skill-profile__slot-btn {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 6px;
width: 100%;
padding: 8px 0;
border: none;
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
color: var(--text1);
}
.skill-profile__slot-top {
font-size: 0.82rem;
color: var(--accent-dark);
font-weight: 600;
}
.skill-profile__slot-top--muted {
color: var(--text3);
font-weight: 500;
}
.skill-profile-compact {
margin-top: 4px;
}
.skill-kpi-grid {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.skill-kpi-grid--loading,
.skill-kpi-grid--empty {
margin: 0;
}
.skill-kpi-tile {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 5.5rem;
max-width: 8.5rem;
padding: 5px 8px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface2);
flex: 0 1 auto;
}
.skill-kpi-tile--highlight {
border-color: var(--accent);
background: var(--accent-light);
}
.skill-kpi-tile--best {
box-shadow: inset 0 0 0 1px var(--accent-dark);
}
.skill-kpi-tile__cat {
font-size: 0.62rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.skill-kpi-tile__name {
font-size: 0.76rem;
font-weight: 600;
color: var(--text1);
line-height: 1.2;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.skill-kpi-tile__score {
font-size: 0.78rem;
font-weight: 700;
color: var(--text1);
margin-top: 2px;
}
.skill-kpi-tile__pct {
font-size: 0.68rem;
font-weight: 600;
color: var(--accent-dark);
}
.skill-profile__name-cat {
font-weight: 500;
color: var(--text3);
}
.skill-profile--embedded .skill-profile__body {
padding: 0;
border-top: none;
}
.skill-profile--embedded .skill-profile__hint {
margin-top: 0;
}
.fw-prog-card__section-head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.fw-prog-card__section--skills {
margin-top: 0.5rem;
}
.fw-import-skill-grid {
max-height: 180px;
overflow-y: auto;
}
.skill-profile-modal {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 24px 12px;
overflow-y: auto;
}
.skill-profile-modal__backdrop {
position: fixed;
inset: 0;
border: none;
background: rgba(0, 0, 0, 0.45);
cursor: pointer;
}
.skill-profile-modal__panel {
position: relative;
z-index: 1;
width: min(42rem, 100%);
max-height: calc(100vh - 48px);
overflow-y: auto;
padding: 1rem 1.1rem 1.25rem;
margin-top: 2vh;
}
.skill-profile-modal__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 0.75rem;
}
.skill-profile-modal__title {
margin: 0;
font-size: 1.05rem;
}
.skill-profile-modal__scale {
margin: 0.5rem 0 0;
}
.skill-discovery {
padding: 1.15rem 1.25rem;
max-width: 52rem;
}
.skill-discovery__title {
margin: 0 0 0.35rem;
font-size: 1.1rem;
}
.skill-discovery__lead {
margin: 0 0 1rem;
max-width: 40rem;
}
.skill-discovery__pick-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 8px;
max-height: 220px;
overflow-y: auto;
padding: 8px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface2);
margin-bottom: 1rem;
}
.skill-discovery__pick {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px 8px;
font-size: 0.85rem;
cursor: pointer;
padding: 4px 6px;
border-radius: 6px;
}
.skill-discovery__pick:hover {
background: var(--surface);
}
.skill-discovery__pick-name {
font-weight: 600;
color: var(--text1);
}
.skill-discovery__pick-cat {
font-size: 0.72rem;
color: var(--text3);
}
.skill-discovery__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 0.75rem;
}
.skill-discovery__error {
color: var(--danger);
margin: 0 0 0.75rem;
}
.skill-discovery__results {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.skill-discovery__result {
padding: 0.85rem 1rem;
margin-bottom: 0;
}
.skill-discovery__result-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 6px;
margin-bottom: 4px;
}
.skill-discovery__result-type {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
color: var(--text3);
}
.skill-discovery__result-match {
font-size: 0.82rem;
font-weight: 700;
color: var(--accent-dark);
}
.skill-discovery__result-title {
display: block;
margin-bottom: 4px;
}
.skill-discovery__result-skills {
margin: 0 0 8px;
font-size: 0.85rem;
color: var(--text2);
}
.skill-discovery__result-cats {
list-style: none;
margin: 0 0 8px;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.8rem;
color: var(--text2);
}
.skill-discovery__result-cats li {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.skill-discovery__result-cat-name {
font-weight: 600;
color: var(--text3);
}
.skill-discovery__result-link {
text-decoration: none;
}
.skill-discovery__no-hit {
margin: 0;
}
/* Rahmenprogramm-Editor (Vollseiten-Formular mit Action-Dock) */
.page-form-editor__body .framework-edit {
min-width: 0;
@ -3618,6 +4086,12 @@ html.modal-scroll-locked .app-main {
.exercise-filter-modal.admin-modal-sheet {
max-width: min(920px, calc(100dvw - 16px));
max-height: min(92vh, 920px);
}
@media (min-width: 640px) {
.exercise-filter-modal.admin-modal-sheet {
max-height: min(90vh, 920px);
}
}
.exercise-filter-modal .admin-modal-sheet__body.exercise-filter-modal__scroll {
flex: 1;
@ -5386,12 +5860,16 @@ html.modal-scroll-locked .app-main {
border-color: var(--border2);
}
/* Rahmenprogramm-Filter auf Übersichtsseite */
.fw-prog-filter-block--list {
/* Rahmenprogramm-/Modul-Filter auf Übersichtsseite */
.fw-prog-filter-block--list,
.planning-list-filter-bar {
margin-bottom: 1rem;
}
.fw-prog-filter-block--list .fw-import-filter-panel {
margin-top: 0.75rem;
.planning-list-filter-bar__search {
margin-bottom: 0.75rem;
}
.planning-list-filter-bar .fw-import-results-bar {
margin-top: 0;
}
/* —— Rahmenprogramm-Bibliothek (Liste) —— */
@ -5946,6 +6424,24 @@ html.modal-scroll-locked .app-main {
max-height: min(360px, 55vh);
overflow: auto;
}
.skill-tree-multiselect__panel--portal {
position: fixed;
right: auto;
top: auto;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.skill-tree-multiselect__panel--portal .multiselect-combo__list {
position: static;
left: auto;
right: auto;
top: auto;
margin: 0;
max-height: none;
box-shadow: none;
border: none;
border-radius: 0;
}
.skill-tree {
list-style: none;
margin: 0;

View File

@ -5,6 +5,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import SkillProfilePanel from './skills/SkillProfilePanel'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import ExercisePickerModal from './ExercisePickerModal'
@ -136,6 +137,9 @@ export default function ExerciseProgressionGraphPanel({
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
const [notesDraft, setNotesDraft] = useState('')
const [uiTab, setUiTab] = useState('overview')
const [skillProfileData, setSkillProfileData] = useState(null)
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
const [skillProfileError, setSkillProfileError] = useState('')
useEffect(() => {
setSelectedGraphId(null)
@ -180,6 +184,32 @@ export default function ExerciseProgressionGraphPanel({
}
}, [refreshGraphs, tenantClubDepKey])
useEffect(() => {
if (!selectedGraphId) {
setSkillProfileData(null)
return undefined
}
let cancelled = false
;(async () => {
setSkillProfileLoading(true)
setSkillProfileError('')
try {
const data = await api.getProgressionGraphSkillProfile(selectedGraphId)
if (!cancelled) setSkillProfileData(data)
} catch (e) {
if (!cancelled) {
setSkillProfileData(null)
setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen')
}
} finally {
if (!cancelled) setSkillProfileLoading(false)
}
})()
return () => {
cancelled = true
}
}, [selectedGraphId, edges.length])
useEffect(() => {
if (!selectedGraphId) {
setEdges([])
@ -652,6 +682,15 @@ export default function ExerciseProgressionGraphPanel({
{selectedGraphId && uiTab === 'overview' && (
<>
<SkillProfilePanel
title="Fähigkeiten entlang des Pfads"
hint="Alle Übungen als Knoten im Graph (Standardgewicht pro Übung; Intensität und Stufen aus der Übungsverknüpfung)."
profile={skillProfileData?.overall}
loading={skillProfileLoading}
error={skillProfileError}
defaultExpanded
artifactType="progression_graph"
/>
<div className="card" style={{ marginBottom: '12px' }}>
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Sequenz / Reihe anlegen</h3>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0 }}>

View File

@ -1,7 +1,12 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { collectSkillLeavesFromTree, buildSkillCatalogTree } from '../utils/skillCatalogTree'
import SkillTreePickerPanel from './SkillTreePickerPanel'
const PANEL_MAX_HEIGHT = 360
const PANEL_MIN_HEIGHT = 140
const PANEL_Z_INDEX = 1100
function normId(id) {
return String(id)
}
@ -17,11 +22,15 @@ export default function SkillTreeMultiSelect({
browseLabel = '▼ Katalog',
emptyHint = 'Keine Treffer',
className = '',
usePortal = true,
}) {
const [query, setQuery] = useState('')
const [open, setOpen] = useState(false)
const [browseTree, setBrowseTree] = useState(false)
const [panelStyle, setPanelStyle] = useState(null)
const rootRef = useRef(null)
const fieldRef = useRef(null)
const panelRef = useRef(null)
const tree = useMemo(() => buildSkillCatalogTree(skills), [skills])
const selectedSet = useMemo(() => new Set(value.map(normId)), [value])
@ -56,17 +65,99 @@ export default function SkillTreeMultiSelect({
useEffect(() => {
const onDoc = (e) => {
if (!rootRef.current?.contains(e.target)) {
const t = e.target
if (rootRef.current?.contains(t) || panelRef.current?.contains(t)) return
setOpen(false)
setBrowseTree(false)
}
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
const updatePanelPosition = useCallback(() => {
const anchor = fieldRef.current
if (!anchor) return
const rect = anchor.getBoundingClientRect()
const gap = 4
const margin = 12
const spaceBelow = window.innerHeight - rect.bottom - gap - margin
const spaceAbove = rect.top - gap - margin
const openUp = spaceBelow < PANEL_MIN_HEIGHT && spaceAbove > spaceBelow
const available = Math.max(0, openUp ? spaceAbove : spaceBelow)
const maxHeight = Math.min(PANEL_MAX_HEIGHT, Math.max(PANEL_MIN_HEIGHT, available))
const top = openUp ? Math.max(margin, rect.top - gap - maxHeight) : rect.bottom + gap
setPanelStyle({
position: 'fixed',
left: rect.left,
width: rect.width,
top,
maxHeight,
zIndex: PANEL_Z_INDEX,
})
}, [])
useLayoutEffect(() => {
if (!open || !usePortal) {
setPanelStyle(null)
return undefined
}
updatePanelPosition()
window.addEventListener('resize', updatePanelPosition)
window.addEventListener('scroll', updatePanelPosition, true)
return () => {
window.removeEventListener('resize', updatePanelPosition)
window.removeEventListener('scroll', updatePanelPosition, true)
}
}, [open, usePortal, updatePanelPosition, query, browseTree, value.length])
const showTree = browseTree || !query.trim()
const panelContent = showTree ? (
<SkillTreePickerPanel
skills={skills}
excludeIds={value}
searchQuery={query}
onPickSkill={(id) => addId(id)}
pickMode="multi"
/>
) : (
<ul className="multiselect-combo__list" role="listbox">
{leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length === 0 ? (
<li className="multiselect-combo__empty">{emptyHint}</li>
) : (
leaves
.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase()))
.map((l) => (
<li key={normId(l.id)}>
<button
type="button"
className="multiselect-combo__opt skill-tree__pick--path"
onMouseDown={(e) => e.preventDefault()}
onClick={() => addId(l.id)}
>
<span className="skill-tree__pick-name">{l.label}</span>
<span className="skill-tree__pick-path">{l.pathLabel}</span>
</button>
</li>
))
)}
</ul>
)
const dropdownPanel =
open && (usePortal ? panelStyle : true) ? (
<div
ref={panelRef}
className={
'skill-tree-multiselect__panel' + (usePortal ? ' skill-tree-multiselect__panel--portal' : '')
}
style={usePortal ? panelStyle : undefined}
>
{panelContent}
</div>
) : null
return (
<div className={`multiselect-combo skill-tree-multiselect ${className}`.trim()} ref={rootRef}>
<div className="multiselect-combo__chips">
@ -85,7 +176,7 @@ export default function SkillTreeMultiSelect({
</button>
))}
</div>
<div className="multiselect-combo__field">
<div className="multiselect-combo__field" ref={fieldRef}>
<input
type="text"
className="form-input multiselect-combo__input"
@ -113,42 +204,8 @@ export default function SkillTreeMultiSelect({
{browseLabel}
</button>
</div>
{open ? (
<div className="skill-tree-multiselect__panel">
{showTree ? (
<SkillTreePickerPanel
skills={skills}
excludeIds={value}
searchQuery={query}
onPickSkill={(id) => addId(id)}
pickMode="multi"
/>
) : (
<ul className="multiselect-combo__list" role="listbox">
{leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length ===
0 ? (
<li className="multiselect-combo__empty">{emptyHint}</li>
) : (
leaves
.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase()))
.map((l) => (
<li key={normId(l.id)}>
<button
type="button"
className="multiselect-combo__opt skill-tree__pick--path"
onMouseDown={(e) => e.preventDefault()}
onClick={() => addId(l.id)}
>
<span className="skill-tree__pick-name">{l.label}</span>
<span className="skill-tree__pick-path">{l.pathLabel}</span>
</button>
</li>
))
)}
</ul>
)}
</div>
) : null}
{!usePortal && dropdownPanel}
{usePortal && dropdownPanel ? createPortal(dropdownPanel, document.body) : null}
</div>
)
}

View File

@ -1,5 +1,6 @@
import React from 'react'
import NavStateLink from '../NavStateLink'
import SkillProfileCompact from '../skills/SkillProfileCompact'
import {
frameworkSessionDurationLabel,
splitFrameworkCommaAgg,
@ -26,7 +27,16 @@ function CatalogGroup({ label, items, variant }) {
/**
* Einzelkarte für die Rahmenprogramm-Bibliothek.
*/
export default function FrameworkProgramListCard({ row, returnContext, onDelete }) {
export default function FrameworkProgramListCard({
row,
returnContext,
onDelete,
skillSummary = null,
skillSummaryLoading = false,
skillFilterIds = [],
skillDisplayLimit = 6,
onShowSkillProfile,
}) {
const title = (row.title || '').trim() || `Rahmen #${row.id}`
const description = (row.description || '').trim()
const durationLabel = frameworkSessionDurationLabel(row)
@ -112,6 +122,28 @@ export default function FrameworkProgramListCard({ row, returnContext, onDelete
</section>
) : null}
<section className="fw-prog-card__section fw-prog-card__section--skills">
<div className="fw-prog-card__section-head">
<h3 className="fw-prog-card__section-title">Fähigkeiten</h3>
{onShowSkillProfile ? (
<button
type="button"
className="btn btn-secondary btn-small fw-prog-card__skills-btn"
onClick={() => onShowSkillProfile(row)}
>
Alle anzeigen
</button>
) : null}
</div>
<SkillProfileCompact
summary={skillSummary}
artifactType="framework_program"
loading={skillSummaryLoading}
displayLimit={skillDisplayLimit}
highlightSkillIds={skillFilterIds}
/>
</section>
<footer className="fw-prog-card__actions">
<NavStateLink
to={`/planning/framework-programs/${row.id}`}

View File

@ -1,299 +1,179 @@
import React, { useMemo } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import {
collectDistinctSessionDurationsMinutes,
EMPTY_FRAMEWORK_IMPORT_FILTERS,
filterFrameworkPrograms,
hasActiveFrameworkImportFilters,
summarizeFrameworkImportFilters,
} from '../../utils/frameworkProgramListHelpers'
import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
import { buildPlanningArtifactFilterChips } from '../../utils/planningArtifactFilterChips'
import PlanningArtifactFilterModal from './PlanningArtifactFilterModal'
/**
* Gemeinsamer Filter für Rahmenprogramm-Liste und Import-Dialog.
* Filter-Leiste für Rahmenprogramm-Liste und Import-Dialog (UX wie Übungsliste).
*/
export default function FrameworkProgramsFilterBlock({
programs = [],
filters,
onFiltersChange,
panelOpen = true,
onPanelOpenChange,
catalogFocusAreas = [],
catalogTrainingTypes = [],
catalogTargetGroups = [],
catalogSkills = [],
skillSummaries = null,
disabled = false,
durationRadioName = 'fw-duration-mode',
showHint = true,
className = '',
searchPlaceholder = 'Suche (Titel, Ziele, Katalog) …',
filterModalTitle = 'Rahmenprogramme filtern',
resultLabel = 'Rahmenprogramm',
}) {
const distinctDurations = useMemo(
() => collectDistinctSessionDurationsMinutes(programs),
[programs]
)
const [filterModalOpen, setFilterModalOpen] = useState(false)
const matchCount = useMemo(
() => filterFrameworkPrograms(programs, filters).length,
[programs, filters]
() => filterFrameworkPrograms(programs, filters, skillSummaries).length,
[programs, filters, skillSummaries]
)
const totalCount = (programs || []).length
const filterActive = hasActiveFrameworkImportFilters(filters)
const filterSummaryParts = useMemo(
const filterChips = useMemo(
() =>
summarizeFrameworkImportFilters(filters, {
buildPlanningArtifactFilterChips({
filters,
setFilters: onFiltersChange,
catalogs: {
focusAreas: catalogFocusAreas,
trainingTypes: catalogTrainingTypes,
targetGroups: catalogTargetGroups,
skills: catalogSkills,
},
artifactType: 'framework_program',
emptyFilters: EMPTY_FRAMEWORK_IMPORT_FILTERS,
}),
[filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups]
[
filters,
onFiltersChange,
catalogFocusAreas,
catalogTrainingTypes,
catalogTargetGroups,
catalogSkills,
]
)
const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
const clearFilters = () => onFiltersChange({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
const toggleId = (key, id) => {
const s = String(id)
onFiltersChange((prev) => {
const cur = prev[key] || []
const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
return { ...prev, [key]: next }
})
useEffect(() => {
if (!filterModalOpen) return undefined
const onKey = (e) => {
if (e.key === 'Escape') setFilterModalOpen(false)
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [filterModalOpen])
const togglePanel = () => {
if (onPanelOpenChange) onPanelOpenChange(!panelOpen)
}
const resultPlural = totalCount === 1 ? resultLabel : `${resultLabel}e`
return (
<div className={'fw-prog-filter-block' + (className ? ` ${className}` : '')}>
<div className={'fw-prog-filter-block planning-list-filter-bar' + (className ? ` ${className}` : '')}>
<div className="card exercise-search-bar planning-list-filter-bar__search">
<label className="form-label">Suche</label>
<input
type="search"
className="form-input exercise-search-bar__primary"
value={filters.query}
onChange={(e) => onFiltersChange((prev) => ({ ...prev, query: e.target.value }))}
placeholder={searchPlaceholder}
disabled={disabled}
enterKeyHint="search"
/>
<div className="exercise-search-bar__actions exercise-search-bar__actions--split">
<div className="exercise-search-bar__actions-main">
<button
type="button"
className="btn btn-secondary exercise-filter-trigger"
disabled={disabled}
onClick={() => setFilterModalOpen(true)}
>
Filter
{filterChips.length > 0 ? (
<span className="exercise-filter-badge" aria-hidden>
{filterChips.length}
</span>
) : null}
</button>
{filterChips.length > 0 ? (
<button type="button" className="btn" disabled={disabled} onClick={clearFilters}>
Alle entfernen
</button>
) : null}
</div>
</div>
{filterChips.length > 0 ? (
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
{filterChips.map((c) => (
<button
key={c.key}
type="button"
role="listitem"
className="exercise-filter-chip"
title="Filter entfernen"
disabled={disabled}
onClick={() => c.onRemove()}
>
<span className="exercise-filter-chip__text">{c.label}</span>
<span className="exercise-filter-chip__x" aria-hidden>
×
</span>
</button>
))}
</div>
) : null}
{showHint ? (
<p className="exercise-search-hint form-sub" style={{ marginBottom: 0 }}>
Fachliche Filter über Filter zwischen Feldern UND. Fähigkeiten vergleichen nur unter
Rahmenprogrammen.
</p>
) : null}
</div>
<div className="fw-import-results-bar">
<div className="fw-import-results-bar__count">
<strong className="fw-import-results-bar__num">{matchCount}</strong>
<span>
{' '}
von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'}
von {totalCount} {resultPlural}
</span>
{matchCount === 0 && totalCount > 0 ? (
<span className="fw-import-results-bar__warn"> kein Treffer</span>
) : null}
</div>
{filterActive ? (
<div className="fw-import-results-bar__actions">
{filterActive ? (
<span className="fw-import-filter-badge" title={filterSummaryParts.join(' · ')}>
Filter aktiv
</span>
) : null}
{filterActive ? (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled={disabled}
onClick={clearFilters}
>
Filter zurücksetzen
</button>
) : null}
{onPanelOpenChange ? (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
disabled={disabled}
onClick={togglePanel}
aria-expanded={panelOpen}
>
{panelOpen ? 'Filter einklappen' : 'Filter anzeigen'}
</button>
<span className="fw-import-filter-badge">Filter aktiv</span>
</div>
) : null}
</div>
</div>
{!panelOpen && filterActive && filterSummaryParts.length > 0 ? (
<ul className="fw-import-filter-chips" aria-label="Aktive Filter">
{filterSummaryParts.map((part) => (
<li key={part} className="fw-import-filter-chip">
{part}
</li>
))}
</ul>
) : null}
{panelOpen ? (
<div className="fw-import-filter-panel">
<div className="fw-import-filter-panel__grid">
<div className="form-row fw-import-filter-panel__search">
<label className="form-label">Suche (Titel, Ziele, Katalog)</label>
<input
className="form-input"
value={filters.query}
onChange={(e) => updateFilter({ query: e.target.value })}
placeholder="z. B. Gürtel, Koordination …"
<PlanningArtifactFilterModal
open={filterModalOpen}
onClose={() => setFilterModalOpen(false)}
filters={filters}
onFiltersChange={onFiltersChange}
artifactType="framework_program"
programs={programs}
catalogFocusAreas={catalogFocusAreas}
catalogTrainingTypes={catalogTrainingTypes}
catalogTargetGroups={catalogTargetGroups}
catalogSkills={catalogSkills}
durationRadioName={durationRadioName}
onResetAll={clearFilters}
disabled={disabled}
title={filterModalTitle}
showCatalogFilters
showDurationFilters
/>
</div>
<fieldset className="fw-import-duration-fieldset">
<legend className="form-label">Ziel-Session-Dauer</legend>
<div className="fw-import-duration-mode" role="radiogroup" aria-label="Dauer-Filtermodus">
{[
{ id: 'any', label: 'Alle' },
{ id: 'range', label: 'Zeitspanne' },
{ id: 'preset', label: 'Vorhandene Zeiten' },
].map((opt) => (
<label key={opt.id} className="fw-import-duration-mode__opt">
<input
type="radio"
name={durationRadioName}
checked={filters.durationMode === opt.id}
disabled={disabled || (opt.id === 'preset' && distinctDurations.length === 0)}
onChange={() =>
updateFilter({
durationMode: opt.id,
...(opt.id === 'any'
? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
: {}),
})
}
/>
<span>{opt.label}</span>
</label>
))}
</div>
{filters.durationMode === 'range' ? (
<div className="responsive-grid-2 fw-import-duration-range">
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Von (Minuten)</label>
<input
type="number"
min={0}
className="form-input"
value={filters.durationRangeFrom}
onChange={(e) =>
updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value })
}
placeholder="z. B. 60"
disabled={disabled}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Bis (Minuten)</label>
<input
type="number"
min={0}
className="form-input"
value={filters.durationRangeTo}
onChange={(e) =>
updateFilter({ durationMode: 'range', durationRangeTo: e.target.value })
}
placeholder="z. B. 90"
disabled={disabled}
/>
</div>
</div>
) : null}
{filters.durationMode === 'preset' ? (
distinctDurations.length === 0 ? (
<p className="form-sub" style={{ margin: '8px 0 0' }}>
In der Bibliothek sind noch keine Session-Dauern hinterlegt. Nutze Zeitspanne oder lege
Dauer pro Session im Rahmenprogramm fest.
</p>
) : (
<div className="fw-import-duration-presets">
{distinctDurations.map((min) => {
const on = filters.durationPresetMin === min
return (
<button
key={min}
type="button"
className={
'btn framework-ctrl framework-ctrl--xs' +
(on ? ' fw-import-duration-preset--on' : ' btn-secondary')
}
disabled={disabled}
onClick={() =>
updateFilter({
durationMode: 'preset',
durationPresetMin: on ? null : min,
durationRangeFrom: '',
durationRangeTo: '',
})
}
>
{formatDurationDisplay(min)}
</button>
)
})}
</div>
)
) : null}
</fieldset>
{catalogFocusAreas.length > 0 ? (
<div className="fw-import-catalog-block">
<span className="form-label">Fokusbereich</span>
<div className="framework-catalog-checkgrid">
{catalogFocusAreas.map((fa) => (
<label key={fa.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(filters.focusAreaIds || []).includes(String(fa.id))}
onChange={() => toggleId('focusAreaIds', fa.id)}
disabled={disabled}
/>
<span>{fa.name}</span>
</label>
))}
</div>
</div>
) : null}
{catalogTrainingTypes.length > 0 ? (
<div className="fw-import-catalog-block">
<span className="form-label">Trainingsart</span>
<div className="framework-catalog-checkgrid">
{catalogTrainingTypes.map((t) => (
<label key={t.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(filters.trainingTypeIds || []).includes(String(t.id))}
onChange={() => toggleId('trainingTypeIds', t.id)}
disabled={disabled}
/>
<span>{t.name}</span>
</label>
))}
</div>
</div>
) : null}
{catalogTargetGroups.length > 0 ? (
<div className="fw-import-catalog-block">
<span className="form-label">Zielgruppe</span>
<div className="framework-catalog-checkgrid">
{catalogTargetGroups.map((tg) => (
<label key={tg.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(filters.targetGroupIds || []).includes(String(tg.id))}
onChange={() => toggleId('targetGroupIds', tg.id)}
disabled={disabled}
/>
<span>{tg.name}</span>
</label>
))}
</div>
</div>
) : null}
</div>
{showHint ? (
<p className="form-sub fw-import-filter-panel__hint">
Entwicklungsziele sind freie Texte die Suche durchsucht auch Ziel-Titel. Bei der Dauer werden nur
Programme mit hinterlegter Session-Dauer berücksichtigt.
</p>
) : null}
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,275 @@
import React, { useMemo } from 'react'
import { collectDistinctSessionDurationsMinutes } from '../../utils/frameworkProgramListHelpers'
import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
import PlanningSkillFilterSection from './PlanningSkillFilterSection'
/**
* Filter-Modal für Rahmenprogramme / Trainingsmodule (UX wie ExerciseListFilterModal).
*/
export default function PlanningArtifactFilterModal({
open,
onClose,
filters,
onFiltersChange,
artifactType = 'framework_program',
programs = [],
catalogFocusAreas = [],
catalogTrainingTypes = [],
catalogTargetGroups = [],
catalogSkills = [],
durationRadioName = 'planning-duration-mode',
onResetAll,
disabled = false,
showCatalogFilters = true,
showDurationFilters = true,
title = 'Filtern',
}) {
const distinctDurations = useMemo(
() => (showDurationFilters ? collectDistinctSessionDurationsMinutes(programs) : []),
[programs, showDurationFilters]
)
if (!open) return null
const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
const toggleId = (key, id) => {
const s = String(id)
onFiltersChange((prev) => {
const cur = prev[key] || []
const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
return { ...prev, [key]: next }
})
}
const artifactLabel =
artifactType === 'training_module'
? 'Trainingsmodule'
: artifactType === 'framework_program'
? 'Rahmenprogramme'
: 'Einträge'
return (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
<div
className="admin-modal-sheet exercise-filter-modal"
role="dialog"
aria-modal="true"
aria-labelledby="planning-filter-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="planning-filter-modal-title" className="admin-modal-sheet__title">
{title}
</h3>
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
Zwischen den Bereichen gilt <strong>UND</strong>. Mehrere Katalog-Werte innerhalb eines Feldes
bedeuten <strong>ODER</strong>. Fähigkeiten filtern nach Trainingsgewicht nur unter sichtbaren{' '}
{artifactLabel}.
</p>
{showCatalogFilters ? (
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Katalog</h4>
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog">
{catalogFocusAreas.length > 0 ? (
<div className="fw-import-catalog-block">
<span className="form-label">Fokusbereich</span>
<div className="framework-catalog-checkgrid">
{catalogFocusAreas.map((fa) => (
<label key={fa.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(filters.focusAreaIds || []).includes(String(fa.id))}
onChange={() => toggleId('focusAreaIds', fa.id)}
disabled={disabled}
/>
<span>{fa.name}</span>
</label>
))}
</div>
</div>
) : null}
{catalogTrainingTypes.length > 0 ? (
<div className="fw-import-catalog-block">
<span className="form-label">Trainingsart</span>
<div className="framework-catalog-checkgrid">
{catalogTrainingTypes.map((t) => (
<label key={t.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(filters.trainingTypeIds || []).includes(String(t.id))}
onChange={() => toggleId('trainingTypeIds', t.id)}
disabled={disabled}
/>
<span>{t.name}</span>
</label>
))}
</div>
</div>
) : null}
{catalogTargetGroups.length > 0 ? (
<div className="fw-import-catalog-block">
<span className="form-label">Zielgruppe</span>
<div className="framework-catalog-checkgrid">
{catalogTargetGroups.map((tg) => (
<label key={tg.id} className="framework-catalog-check">
<input
type="checkbox"
checked={(filters.targetGroupIds || []).includes(String(tg.id))}
onChange={() => toggleId('targetGroupIds', tg.id)}
disabled={disabled}
/>
<span>{tg.name}</span>
</label>
))}
</div>
</div>
) : null}
</div>
</section>
) : null}
{showDurationFilters ? (
<section className="exercise-filter-section">
<h4 className="exercise-filter-section-title">Session-Dauer</h4>
<fieldset className="fw-import-duration-fieldset" style={{ border: 0, padding: 0, margin: 0 }}>
<div className="fw-import-duration-mode" role="radiogroup" aria-label="Dauer-Filtermodus">
{[
{ id: 'any', label: 'Alle' },
{ id: 'range', label: 'Zeitspanne' },
{ id: 'preset', label: 'Vorhandene Zeiten' },
].map((opt) => (
<label key={opt.id} className="fw-import-duration-mode__opt">
<input
type="radio"
name={durationRadioName}
checked={filters.durationMode === opt.id}
disabled={disabled || (opt.id === 'preset' && distinctDurations.length === 0)}
onChange={() =>
updateFilter({
durationMode: opt.id,
...(opt.id === 'any'
? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
: {}),
})
}
/>
<span>{opt.label}</span>
</label>
))}
</div>
{filters.durationMode === 'range' ? (
<div className="responsive-grid-2 fw-import-duration-range" style={{ marginTop: 10 }}>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Von (Minuten)</label>
<input
type="number"
min={0}
className="form-input"
value={filters.durationRangeFrom}
onChange={(e) =>
updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value })
}
placeholder="z. B. 60"
disabled={disabled}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Bis (Minuten)</label>
<input
type="number"
min={0}
className="form-input"
value={filters.durationRangeTo}
onChange={(e) =>
updateFilter({ durationMode: 'range', durationRangeTo: e.target.value })
}
placeholder="z. B. 90"
disabled={disabled}
/>
</div>
</div>
) : null}
{filters.durationMode === 'preset' ? (
distinctDurations.length === 0 ? (
<p className="form-sub" style={{ margin: '8px 0 0' }}>
Noch keine Session-Dauern hinterlegt.
</p>
) : (
<div className="fw-import-duration-presets" style={{ marginTop: 10 }}>
{distinctDurations.map((min) => {
const on = filters.durationPresetMin === min
return (
<button
key={min}
type="button"
className={
'btn framework-ctrl framework-ctrl--xs' +
(on ? ' fw-import-duration-preset--on' : ' btn-secondary')
}
disabled={disabled}
onClick={() =>
updateFilter({
durationMode: 'preset',
durationPresetMin: on ? null : min,
durationRangeFrom: '',
durationRangeTo: '',
})
}
>
{formatDurationDisplay(min)}
</button>
)
})}
</div>
)
) : null}
</fieldset>
</section>
) : null}
{catalogSkills.length > 0 ? (
<section className="exercise-filter-section exercise-filter-section--last">
<h4 className="exercise-filter-section-title">Fähigkeit und Trainingsgewicht</h4>
<PlanningSkillFilterSection
artifactType={artifactType}
skillIds={filters.skillIds || []}
onSkillIdsChange={(v) => updateFilter({ skillIds: v })}
skillMinClubPercent={filters.skillMinClubPercent ?? 0}
onSkillMinClubPercentChange={(v) => updateFilter({ skillMinClubPercent: v })}
skillSort={filters.skillSort || 'title'}
onSkillSortChange={(v) => updateFilter({ skillSort: v })}
skillsCatalog={catalogSkills}
disabled={disabled}
/>
</section>
) : null}
</div>
<div className="exercise-filter-modal__footer">
<button type="button" className="btn" onClick={onResetAll} disabled={disabled}>
Alle Filter zurücksetzen
</button>
<button type="button" className="btn btn-primary" onClick={onClose}>
Fertig
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,69 @@
import React from 'react'
import SkillTreeMultiSelect from '../SkillTreeMultiSelect'
import { peerCorpusCountLabel } from '../../utils/skillProfileListHelpers'
/**
* Fähigkeiten-Filter für Planungsartefakte (Rahmenprogramme, Module).
* Semantik wie Übungsliste (SkillTreeMultiSelect), aber mit Peer-Prozent statt Stufen.
*/
export default function PlanningSkillFilterSection({
artifactType = 'framework_program',
skillIds = [],
onSkillIdsChange,
skillMinClubPercent = 0,
onSkillMinClubPercentChange,
skillSort = 'title',
onSkillSortChange,
skillsCatalog = [],
disabled = false,
}) {
const peerLabel = peerCorpusCountLabel(artifactType)
const peerMaxLabel =
artifactType === 'framework_program' ? 'Rahmenprogramm-Maximum' : `${peerLabel}-Maximum`
return (
<div className="exercise-filter-skill-block">
<label className="form-label">Fähigkeit</label>
<SkillTreeMultiSelect
value={skillIds}
onChange={onSkillIdsChange}
skills={skillsCatalog}
placeholder="Fähigkeit suchen …"
/>
<p className="exercise-filter-skill-hint">
Trainingsgewicht relativ zum stärksten sichtbaren Eintrag unter {peerLabel} nicht gemischt mit
anderen Planungs-Artefakttypen.
</p>
{(skillIds || []).length > 0 ? (
<div className="fw-import-skill-options" style={{ marginTop: 10 }}>
<div className="form-row" style={{ marginBottom: 8 }}>
<label className="form-label">Mindest-Anteil am {peerMaxLabel}</label>
<select
className="form-input"
value={String(skillMinClubPercent ?? 0)}
disabled={disabled}
onChange={(e) => onSkillMinClubPercentChange(Number(e.target.value) || 0)}
>
<option value="0">Kein Minimum (nur markieren)</option>
<option value="25">mind. 25%</option>
<option value="50">mind. 50%</option>
<option value="75">mind. 75%</option>
</select>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label">Sortierung</label>
<select
className="form-input"
value={skillSort || 'title'}
disabled={disabled}
onChange={(e) => onSkillSortChange(e.target.value)}
>
<option value="title">Bibliotheks-Reihenfolge</option>
<option value="skill_strength">Stärkste gewählte Fähigkeit zuerst</option>
</select>
</div>
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,148 @@
import React, { useEffect, useMemo, useState } from 'react'
import {
EMPTY_TRAINING_MODULE_FILTERS,
filterTrainingModules,
hasActiveTrainingModuleFilters,
} from '../../utils/trainingModuleListHelpers'
import { buildPlanningArtifactFilterChips } from '../../utils/planningArtifactFilterChips'
import PlanningArtifactFilterModal from './PlanningArtifactFilterModal'
/**
* Filter-Leiste für Trainingsmodule (UX wie Übungsliste / Rahmenprogramme).
*/
export default function TrainingModulesFilterBlock({
modules = [],
filters,
onFiltersChange,
catalogSkills = [],
skillSummaries = null,
disabled = false,
className = '',
}) {
const [filterModalOpen, setFilterModalOpen] = useState(false)
const matchCount = useMemo(
() => filterTrainingModules(modules, filters, skillSummaries).length,
[modules, filters, skillSummaries]
)
const totalCount = (modules || []).length
const filterActive = hasActiveTrainingModuleFilters(filters)
const filterChips = useMemo(
() =>
buildPlanningArtifactFilterChips({
filters,
setFilters: onFiltersChange,
catalogs: { skills: catalogSkills },
artifactType: 'training_module',
emptyFilters: EMPTY_TRAINING_MODULE_FILTERS,
}),
[filters, onFiltersChange, catalogSkills]
)
const clearFilters = () => onFiltersChange({ ...EMPTY_TRAINING_MODULE_FILTERS })
useEffect(() => {
if (!filterModalOpen) return undefined
const onKey = (e) => {
if (e.key === 'Escape') setFilterModalOpen(false)
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [filterModalOpen])
return (
<div className={'planning-list-filter-bar' + (className ? ` ${className}` : '')}>
<div className="card exercise-search-bar planning-list-filter-bar__search">
<label className="form-label">Suche</label>
<input
type="search"
className="form-input exercise-search-bar__primary"
value={filters.query}
onChange={(e) => onFiltersChange((prev) => ({ ...prev, query: e.target.value }))}
placeholder="Titel oder Kurzbeschreibung …"
disabled={disabled}
enterKeyHint="search"
/>
<div className="exercise-search-bar__actions exercise-search-bar__actions--split">
<div className="exercise-search-bar__actions-main">
<button
type="button"
className="btn btn-secondary exercise-filter-trigger"
disabled={disabled}
onClick={() => setFilterModalOpen(true)}
>
Filter
{filterChips.length > 0 ? (
<span className="exercise-filter-badge" aria-hidden>
{filterChips.length}
</span>
) : null}
</button>
{filterChips.length > 0 ? (
<button type="button" className="btn" disabled={disabled} onClick={clearFilters}>
Alle entfernen
</button>
) : null}
</div>
</div>
{filterChips.length > 0 ? (
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
{filterChips.map((c) => (
<button
key={c.key}
type="button"
role="listitem"
className="exercise-filter-chip"
title="Filter entfernen"
disabled={disabled}
onClick={() => c.onRemove()}
>
<span className="exercise-filter-chip__text">{c.label}</span>
<span className="exercise-filter-chip__x" aria-hidden>
×
</span>
</button>
))}
</div>
) : null}
<p className="exercise-search-hint form-sub" style={{ marginBottom: 0 }}>
Fachliche Filter über Filter. Fähigkeiten vergleichen nur unter sichtbaren Modulen.
</p>
</div>
<div className="fw-import-results-bar">
<div className="fw-import-results-bar__count">
<strong className="fw-import-results-bar__num">{matchCount}</strong>
<span>
{' '}
von {totalCount} Modul{totalCount === 1 ? '' : 'en'}
</span>
{matchCount === 0 && totalCount > 0 ? (
<span className="fw-import-results-bar__warn"> kein Treffer</span>
) : null}
</div>
{filterActive ? (
<div className="fw-import-results-bar__actions">
<span className="fw-import-filter-badge">Filter aktiv</span>
</div>
) : null}
</div>
<PlanningArtifactFilterModal
open={filterModalOpen}
onClose={() => setFilterModalOpen(false)}
filters={filters}
onFiltersChange={onFiltersChange}
artifactType="training_module"
catalogSkills={catalogSkills}
onResetAll={clearFilters}
disabled={disabled}
title="Trainingsmodule filtern"
showCatalogFilters={false}
showDurationFilters={false}
/>
</div>
)
}

View File

@ -35,12 +35,10 @@ export default function TrainingPlanningFrameworkImportModal({
onClose,
}) {
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
const [filterPanelOpen, setFilterPanelOpen] = useState(true)
useEffect(() => {
if (!open) {
setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
setFilterPanelOpen(true)
}
}, [open])
@ -80,13 +78,14 @@ export default function TrainingPlanningFrameworkImportModal({
programs={frameworkProgramsList}
filters={filters}
onFiltersChange={setFilters}
panelOpen={filterPanelOpen}
onPanelOpenChange={setFilterPanelOpen}
catalogFocusAreas={catalogFocusAreas}
catalogTrainingTypes={catalogTrainingTypes}
catalogTargetGroups={catalogTargetGroups}
disabled={fwImportSubmitting}
durationRadioName="fw-duration-mode"
showHint={false}
searchPlaceholder="Rahmenprogramm suchen …"
filterModalTitle="Rahmenprogramme filtern"
/>
<div className="form-row fw-import-program-select">

View File

@ -0,0 +1,185 @@
import React, { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../../utils/api'
const ARTIFACT_LABELS = {
framework_program: 'Rahmenprogramm',
training_module: 'Trainingsmodul',
progression_graph: 'Regressionspfad',
}
function formatScore(value) {
const n = Number(value)
if (!Number.isFinite(n)) return '0'
return n % 1 === 0 ? String(n) : n.toFixed(1)
}
/**
* Vorschläge für Planungsartefakte anhand gewählter Fähigkeiten (Phase 3).
*/
export default function SkillDiscoveryPanel({ skills = [] }) {
const [selectedIds, setSelectedIds] = useState([])
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [result, setResult] = useState(null)
const filteredSkills = useMemo(() => {
const q = query.trim().toLowerCase()
const list = (skills || []).filter((s) => s.status !== 'inactive')
if (!q) return list
return list.filter(
(s) =>
(s.name || '').toLowerCase().includes(q) ||
(s.category || '').toLowerCase().includes(q)
)
}, [skills, query])
const toggleSkill = (id) => {
const s = String(id)
setSelectedIds((prev) =>
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]
)
}
async function handleSearch() {
const ids = selectedIds.map((x) => parseInt(x, 10)).filter((n) => n > 0)
if (!ids.length) {
setError('Wähle mindestens eine Fähigkeit.')
return
}
setLoading(true)
setError('')
setResult(null)
try {
const data = await api.getSkillDiscoverySuggestions(ids, { limit: 25 })
setResult(data)
} catch (e) {
setError(e.message || 'Suche fehlgeschlagen')
} finally {
setLoading(false)
}
}
return (
<div className="skill-discovery card">
<h2 className="skill-discovery__title">Planungs-Vorschläge</h2>
<p className="form-sub skill-discovery__lead">
Wähle Fähigkeiten, die du schwerpunktmäßig entwickeln willst Shinkan schlägt passende
Rahmenprogramme, Trainingsmodule und Regressionspfade vor. Sortierung nach absolutem
Trainingsgewicht (nicht nach Anteil innerhalb des Plans).
</p>
<div className="form-row">
<label className="form-label">Fähigkeiten filtern</label>
<input
className="form-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Name oder Kategorie …"
/>
</div>
<div className="skill-discovery__pick-grid" role="group" aria-label="Fähigkeiten auswählen">
{filteredSkills.length === 0 ? (
<p className="form-sub">Keine Fähigkeiten gefunden.</p>
) : (
filteredSkills.map((sk) => (
<label key={sk.id} className="skill-discovery__pick">
<input
type="checkbox"
checked={selectedIds.includes(String(sk.id))}
onChange={() => toggleSkill(sk.id)}
/>
<span className="skill-discovery__pick-name">{sk.name}</span>
{sk.category ? (
<span className="skill-discovery__pick-cat">{sk.category}</span>
) : null}
</label>
))
)}
</div>
<div className="skill-discovery__actions">
<button
type="button"
className="btn btn-primary"
disabled={loading || selectedIds.length === 0}
onClick={handleSearch}
>
{loading ? 'Suche …' : 'Bibliothek durchsuchen'}
</button>
{selectedIds.length > 0 ? (
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setSelectedIds([])
setResult(null)
setError('')
}}
>
Auswahl leeren
</button>
) : null}
</div>
{error ? <p className="skill-discovery__error">{error}</p> : null}
{result?.suggestions?.length > 0 ? (
<ul className="skill-discovery__results">
{result.suggestions.map((item) => {
const matchScore = item.match?.match_score ?? item.match?.match_weight ?? 0
const focusPct = item.match?.artifact_focus_percent ?? item.match?.match_percent
const topByCat = item.skill_profile_summary?.top_by_category || []
return (
<li key={`${item.artifact_type}-${item.artifact_id}`} className="skill-discovery__result card">
<div className="skill-discovery__result-head">
<span className="skill-discovery__result-type">
{ARTIFACT_LABELS[item.artifact_type] || item.artifact_type}
</span>
<span className="skill-discovery__result-match" title="Summe der Trainingsgewichte der gewählten Fähigkeiten">
Gewicht {formatScore(matchScore)}
</span>
</div>
<strong className="skill-discovery__result-title">
{item.artifact_title || `#${item.artifact_id}`}
</strong>
{item.match?.matched_skills?.length > 0 ? (
<p className="skill-discovery__result-skills">
{item.match.matched_skills.map((m) => m.skill_name).join(' · ')}
{focusPct != null ? ` (${formatScore(focusPct)}% des Plans)` : null}
</p>
) : null}
{topByCat.length > 0 ? (
<ul className="skill-discovery__result-cats">
{topByCat.slice(0, 4).map((row) => (
<li key={`${row.category_name}-${row.skill_id}`}>
<span className="skill-discovery__result-cat-name">{row.category_name}</span>
<span>{row.skill_name}</span>
</li>
))}
</ul>
) : null}
{item.path ? (
<Link to={item.path} className="btn btn-secondary btn-small skill-discovery__result-link">
Öffnen
</Link>
) : item.artifact_type === 'progression_graph' ? (
<p className="form-sub" style={{ margin: '8px 0 0' }}>
Regressionspfad in der Übungsliste unter Progressionsgraph bearbeiten.
</p>
) : null}
</li>
)
})}
</ul>
) : result && !loading ? (
<p className="form-sub skill-discovery__no-hit">
Keine passenden Artefakte in deiner sichtbaren Bibliothek prüfe Fähigkeiten-Verknüpfungen an
den Übungen oder erweitere die Auswahl.
</p>
) : null}
</div>
)
}

View File

@ -0,0 +1,63 @@
import React from 'react'
import {
formatClubPercent,
formatSkillWeight,
kpiRowsFromSummary,
peerPercentSuffix,
} from '../../utils/skillProfileListHelpers'
/**
* Kleine KPI-Kacheln: je Unterkategorie die Top-Fähigkeit (Listen/Karten).
*/
export default function SkillProfileCompact({
summary,
artifactType = 'training_module',
loading = false,
emptyText = 'Keine Fähigkeiten',
displayLimit = 24,
highlightSkillIds = [],
}) {
if (loading) {
return (
<div className="skill-kpi-grid skill-kpi-grid--loading" aria-busy="true">
<span className="form-sub"></span>
</div>
)
}
const rows = kpiRowsFromSummary(summary, { limit: displayLimit })
const highlight = new Set((highlightSkillIds || []).map(String))
const peerLabel = peerPercentSuffix(artifactType)
if (!rows.length) {
return summary ? <p className="form-sub skill-kpi-grid--empty">{emptyText}</p> : null
}
return (
<ul className="skill-kpi-grid" aria-label="Fähigkeiten je Kategorie">
{rows.map((row) => {
const highlighted = highlight.has(String(row.skill_id))
const isBest = row.is_club_best_for_skill || row.universal_percent >= 100
return (
<li
key={`${row.category_name}-${row.skill_id}`}
className={
'skill-kpi-tile' +
(highlighted ? ' skill-kpi-tile--highlight' : '') +
(isBest ? ' skill-kpi-tile--best' : '')
}
title={`${row.category_name}: ${row.skill_name} — Gewicht ${formatSkillWeight(row.weight ?? row.score)}, ${formatClubPercent(row.universal_percent)} unter ${peerLabel}`}
>
<span className="skill-kpi-tile__cat">{row.category_name}</span>
<span className="skill-kpi-tile__name">{row.skill_name}</span>
<span className="skill-kpi-tile__score">{formatSkillWeight(row.weight ?? row.score)}</span>
<span className="skill-kpi-tile__pct">
{isBest ? '★ ' : ''}
{formatClubPercent(row.universal_percent)} {peerLabel}
</span>
</li>
)
})}
</ul>
)
}

View File

@ -0,0 +1,85 @@
import React, { useEffect, useState } from 'react'
import api from '../../utils/api'
import { peerCorpusCountLabel } from '../../utils/skillProfileListHelpers'
import SkillProfilePanel from './SkillProfilePanel'
/**
* Vollständiges Fähigkeiten-Profil in einem Modal (Listen-Kontext).
*/
export default function SkillProfileFullModal({
open,
onClose,
artifactType = 'framework_program',
artifactId,
title = 'Fähigkeiten-Profil',
}) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [data, setData] = useState(null)
useEffect(() => {
if (!open || !artifactId) return undefined
let cancelled = false
setLoading(true)
setError('')
setData(null)
const load =
artifactType === 'training_module'
? api.getTrainingModuleSkillProfile(artifactId)
: artifactType === 'progression_graph'
? api.getProgressionGraphSkillProfile(artifactId)
: api.getFrameworkProgramSkillProfile(artifactId)
load
.then((res) => {
if (!cancelled) setData(res)
})
.catch((e) => {
if (!cancelled) setError(e.message || 'Profil konnte nicht geladen werden')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [open, artifactId, artifactType])
if (!open) return null
const peerCountLabel = peerCorpusCountLabel(artifactType)
const scale = data?.reference_scale
return (
<div className="skill-profile-modal" role="dialog" aria-modal="true" aria-labelledby="skill-profile-modal-title">
<button type="button" className="skill-profile-modal__backdrop" aria-label="Schließen" onClick={onClose} />
<div className="skill-profile-modal__panel card">
<header className="skill-profile-modal__head">
<h2 id="skill-profile-modal-title" className="skill-profile-modal__title">
{title}
</h2>
<button type="button" className="btn btn-secondary btn-small" onClick={onClose}>
Schließen
</button>
</header>
<SkillProfilePanel
profile={data?.overall}
slots={artifactType === 'framework_program' ? data?.slots : null}
loading={loading}
error={error}
title="Vollständiges Profil"
displayMode="full"
embedded
defaultExpanded
artifactType={artifactType}
/>
{scale && !loading ? (
<p className="form-sub skill-profile-modal__scale">
Vergleichsbasis: {scale.artifacts_scanned ?? 0} sichtbare {peerCountLabel} (
{scale.skills_in_corpus ?? 0} Fähigkeiten mit Referenz).
{scale.description ? ` ${scale.description}` : null}
</p>
) : null}
</div>
</div>
)
}

View File

@ -0,0 +1,338 @@
import React, { useMemo, useState } from 'react'
import {
formatClubPercent,
peerPercentSuffix,
} from '../../utils/skillProfileListHelpers'
function skillWeight(skill) {
return Number(skill?.weight ?? skill?.score ?? 0)
}
function formatWeight(value) {
const n = Number(value)
if (!Number.isFinite(n)) return '0'
return n % 1 === 0 ? String(n) : n.toFixed(1)
}
function barFillPercent(skill, maxWeight, hasReferenceScale) {
if (hasReferenceScale && skill?.universal_percent != null) {
return Math.min(100, Number(skill.universal_percent))
}
const w = skillWeight(skill)
return maxWeight > 0 ? Math.min(100, (w / maxWeight) * 100) : 0
}
function metricLabel(skill, hasReferenceScale, peerLabel) {
if (hasReferenceScale && skill?.universal_percent != null) {
const best = skill.is_club_best_for_skill ? ' ★' : ''
return `${formatClubPercent(skill.universal_percent)} ${peerLabel}${best}`
}
return formatWeight(skillWeight(skill))
}
function SkillRow({ skill, maxWeight, hasReferenceScale, peerLabel }) {
if (!skill) return null
const pct = barFillPercent(skill, maxWeight, hasReferenceScale)
return (
<li className="skill-profile__row">
<div className="skill-profile__row-head">
<span className="skill-profile__name" title={skill.skill_name}>
{skill.category_name ? (
<span className="skill-profile__name-cat">{skill.category_name} · </span>
) : null}
{skill.skill_name}
</span>
<span className="skill-profile__pct" title="Trainingsgewicht (gewichtete Minuten)">
{metricLabel(skill, hasReferenceScale, peerLabel)}
</span>
</div>
<div className="skill-profile__bar-track" aria-hidden="true">
<div className="skill-profile__bar-fill" style={{ width: `${pct}%` }} />
</div>
{hasReferenceScale ? (
<span className="skill-profile__meta-hint">
Gewicht {formatWeight(skillWeight(skill))}
{skill.club_best ? (
<>
{' '}
· Top unter {peerLabel}: {skill.club_best.artifact_title || skill.club_best.artifact_id} (
{formatWeight(skill.club_best.weight)})
</>
) : skill.is_club_best_for_skill ? (
' · Stärkster unter ' + peerLabel
) : null}
</span>
) : null}
</li>
)
}
function CategoryTopSkill({ skill, maxWeight, hasReferenceScale, peerLabel }) {
if (!skill) return null
return (
<SkillRow
skill={skill}
maxWeight={maxWeight}
hasReferenceScale={hasReferenceScale}
peerLabel={peerLabel}
/>
)
}
function CategoryGroupedProfile({ profile, ariaLabel, peerLabel }) {
const groups = profile?.by_main_category || []
const hasReferenceScale = Boolean(profile?.has_reference_scale)
const maxWeight = useMemo(() => {
let max = 1
for (const mc of groups) {
for (const cat of mc.categories || []) {
const w = skillWeight(cat.top_skill)
if (w > max) max = w
}
}
return max
}, [groups])
if (!groups.length) return null
return (
<div className="skill-profile__by-category" aria-label={ariaLabel}>
{groups.map((mc) => (
<section key={mc.main_category_id ?? mc.main_category_name} className="skill-profile__main-cat">
<h4 className="skill-profile__main-cat-title">{mc.main_category_name}</h4>
<ul className="skill-profile__cat-list">
{(mc.categories || []).map((cat) => (
<li key={cat.category_id ?? cat.category_name} className="skill-profile__cat-item">
<span className="skill-profile__cat-label">{cat.category_name}</span>
<div className="skill-profile__cat-row">
<CategoryTopSkill
skill={cat.top_skill}
maxWeight={maxWeight}
hasReferenceScale={hasReferenceScale}
peerLabel={peerLabel}
/>
</div>
</li>
))}
</ul>
</section>
))}
</div>
)
}
function FullSkillsProfile({ profile, ariaLabel, peerLabel }) {
const skills = profile?.skills || []
const hasReferenceScale = Boolean(profile?.has_reference_scale)
const maxWeight = useMemo(
() => Math.max(...skills.map((s) => skillWeight(s)), 1),
[skills]
)
if (!skills.length) return null
return (
<ul className="skill-profile__list" aria-label={ariaLabel}>
{skills.map((sk) => (
<SkillRow
key={sk.skill_id}
skill={sk}
maxWeight={maxWeight}
hasReferenceScale={hasReferenceScale}
peerLabel={peerLabel}
/>
))}
</ul>
)
}
function topCategoryBadge(profile) {
const parts = []
for (const mc of profile?.by_main_category || []) {
for (const cat of mc.categories || []) {
const top = cat.top_skill
if (!top) continue
parts.push(`${cat.category_name}: ${top.skill_name}`)
if (parts.length >= 2) return parts.join(' · ')
}
}
return parts.join(' · ')
}
/**
* Gewichtetes Fähigkeiten-Profil (Phase 3) Anzeige für Planungsartefakte.
* displayMode: 'summary' = Top je Kategorie (Editor), 'full' = alle Fähigkeiten (Modal).
*/
export default function SkillProfilePanel({
profile,
slots = null,
loading = false,
error = '',
title = 'Fähigkeiten-Profil',
hint = '',
defaultExpanded = true,
displayMode = 'summary',
embedded = false,
artifactType = 'framework_program',
}) {
const [expanded, setExpanded] = useState(defaultExpanded)
const [slotOpenId, setSlotOpenId] = useState(null)
const peerLabel = peerPercentSuffix(artifactType)
const defaultHint =
displayMode === 'full'
? `Alle verknüpften Fähigkeiten nach Trainingsgewicht. Prozent = Anteil am stärksten sichtbaren Eintrag unter ${peerLabel} je Fähigkeit.`
: `Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Prozent vergleicht nur unter ${peerLabel}, nicht mit anderen Planungs-Artefakttypen.`
const hintText = hint || defaultHint
const badge = useMemo(() => topCategoryBadge(profile), [profile])
if (loading) {
return (
<div className={'skill-profile skill-profile--loading' + (embedded ? '' : ' card')}>
<p className="skill-profile__status">Fähigkeiten-Profil wird berechnet</p>
</div>
)
}
if (error) {
return (
<div className={'skill-profile skill-profile--error' + (embedded ? '' : ' card')}>
<p className="skill-profile__status">{error}</p>
</div>
)
}
const noData =
!profile ||
(profile.exercise_occurrence_count === 0 && profile.distinct_exercise_count === 0)
const categoryCount = (profile?.by_main_category || []).reduce(
(n, mc) => n + (mc.categories?.length || 0),
0
)
const body = (
<div className="skill-profile__body">
<p className="form-sub skill-profile__hint">{hintText}</p>
{noData ? (
<p className="skill-profile__empty">
Noch keine Übungen mit Fähigkeiten-Verknüpfung lege Übungen im Ablauf an und verknüpfe
Fähigkeiten in der Übungsbearbeitung.
</p>
) : profile.exercises_with_skills_count === 0 ? (
<p className="skill-profile__empty">
{profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im
Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt.
</p>
) : (
<>
<div className="skill-profile__stats">
<span>
<strong>{profile.distinct_exercise_count}</strong> Übungen
</span>
<span>
<strong>{profile.skills?.length ?? 0}</strong> Fähigkeiten
</span>
{displayMode === 'summary' ? (
<span>
<strong>{categoryCount}</strong> Kategorien
</span>
) : null}
<span>
<strong>{formatWeight(profile.total_score ?? profile.total_weight)}</strong> Gesamt-Gewicht
</span>
</div>
{displayMode === 'full' ? (
<FullSkillsProfile
profile={profile}
ariaLabel="Alle Fähigkeiten nach Gewicht"
peerLabel={peerLabel}
/>
) : (
<CategoryGroupedProfile
profile={profile}
ariaLabel="Top-Fähigkeit je Kategorie"
peerLabel={peerLabel}
/>
)}
</>
)}
{slots && slots.length > 0 ? (
<div className="skill-profile__slots">
<span className="skill-profile__slots-label">Pro Session</span>
<ul className="skill-profile__slot-list">
{slots.map((sl) => {
const open = slotOpenId === sl.slot_id
const slotBadge = topCategoryBadge(sl.profile)
return (
<li key={sl.slot_id} className="skill-profile__slot-item">
<button
type="button"
className="skill-profile__slot-btn"
onClick={() => setSlotOpenId(open ? null : sl.slot_id)}
aria-expanded={open}
>
<span>
{(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
</span>
{slotBadge ? (
<span className="skill-profile__slot-top">{slotBadge}</span>
) : (
<span className="skill-profile__slot-top skill-profile__slot-top--muted">
{sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
</span>
)}
</button>
{open && sl.profile?.skills?.length > 0 ? (
displayMode === 'full' ? (
<FullSkillsProfile
profile={sl.profile}
ariaLabel={`Alle Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
peerLabel={peerLabel}
/>
) : (
<CategoryGroupedProfile
profile={sl.profile}
ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
peerLabel={peerLabel}
/>
)
) : null}
</li>
)
})}
</ul>
</div>
) : null}
</div>
)
if (embedded) {
return <div className="skill-profile skill-profile--embedded">{body}</div>
}
return (
<div className="card skill-profile">
<button
type="button"
className="skill-profile__toggle"
onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded}
>
<span className="skill-profile__toggle-title">{title}</span>
{!noData && badge ? (
<span className="skill-profile__toggle-badge">{badge}</span>
) : null}
<span className="skill-profile__toggle-icon" aria-hidden="true">
{expanded ? '▾' : '▸'}
</span>
</button>
{expanded ? body : null}
</div>
)
}

View File

@ -2,10 +2,12 @@ import React, { useState, useEffect } from 'react'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import PageSectionNav from '../components/PageSectionNav'
import SkillDiscoveryPanel from '../components/skills/SkillDiscoveryPanel'
const SKILLS_SECTION_TABS = [
{ id: 'skills', label: 'Fähigkeiten' },
{ id: 'methods', label: 'Trainingsmethoden' },
{ id: 'discovery', label: 'Planungs-Vorschläge' },
]
function SkillsPage() {
@ -243,6 +245,8 @@ function SkillsPage() {
</>
)}
{activeTab === 'discovery' && <SkillDiscoveryPanel skills={skills} />}
{/* Methods Tab */}
{activeTab === 'methods' && (
<>

View File

@ -6,6 +6,7 @@ import ExercisePeekModal from '../components/ExercisePeekModal'
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
import PageSectionNav from '../components/PageSectionNav'
import PageFormEditorChrome from '../components/PageFormEditorChrome'
import SkillProfilePanel from '../components/skills/SkillProfilePanel'
import { useToast } from '../context/ToastContext'
import { useNavReturn } from '../hooks/useNavReturn'
import {
@ -248,6 +249,10 @@ export default function TrainingFrameworkProgramEditPage() {
)
/** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */
const [mobileSlotIdx, setMobileSlotIdx] = useState(0)
const [skillProfileData, setSkillProfileData] = useState(null)
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
const [skillProfileError, setSkillProfileError] = useState('')
const [skillProfileTick, setSkillProfileTick] = useState(0)
const toast = useToast()
const baselineRef = useRef(null)
@ -301,6 +306,32 @@ export default function TrainingFrameworkProgramEditPage() {
return () => document.removeEventListener('pointerdown', onPointerDown, true)
}, [])
useEffect(() => {
if (isNew || !idParam) {
setSkillProfileData(null)
return undefined
}
let cancelled = false
;(async () => {
setSkillProfileLoading(true)
setSkillProfileError('')
try {
const data = await api.getFrameworkProgramSkillProfile(idParam)
if (!cancelled) setSkillProfileData(data)
} catch (e) {
if (!cancelled) {
setSkillProfileData(null)
setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen')
}
} finally {
if (!cancelled) setSkillProfileLoading(false)
}
})()
return () => {
cancelled = true
}
}, [isNew, idParam, skillProfileTick])
const loadMeta = useCallback(async () => {
try {
const [cl, fa, sd, tt, tg] = await Promise.all([
@ -480,6 +511,7 @@ export default function TrainingFrameworkProgramEditPage() {
setBypassDirty(false)
setBaselineReady(true)
toast.success('Gespeichert.')
setSkillProfileTick((t) => t + 1)
if (closeAfter) goBack()
return true
} catch (e) {
@ -940,6 +972,17 @@ export default function TrainingFrameworkProgramEditPage() {
/>
</div>
{!isNew ? (
<SkillProfilePanel
title="Fähigkeiten-Schwerpunkte (aus Übungen)"
profile={skillProfileData?.overall}
slots={skillProfileData?.slots}
loading={skillProfileLoading}
error={skillProfileError}
artifactType="framework_program"
/>
) : null}
<div
className={
'framework-edit__panel framework-edit__panel--meta card' +

View File

@ -3,6 +3,7 @@ import api from '../utils/api'
import NavStateLink from '../components/NavStateLink'
import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock'
import FrameworkProgramListCard from '../components/planning/FrameworkProgramListCard'
import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
@ -22,12 +23,15 @@ export default function TrainingFrameworkProgramsListPage() {
const [catalogFocusAreas, setCatalogFocusAreas] = useState([])
const [catalogTrainingTypes, setCatalogTrainingTypes] = useState([])
const [catalogTargetGroups, setCatalogTargetGroups] = useState([])
const [catalogSkills, setCatalogSkills] = useState([])
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
const [filterPanelOpen, setFilterPanelOpen] = useState(true)
const [skillSummaries, setSkillSummaries] = useState({})
const [summariesLoading, setSummariesLoading] = useState(false)
const [profileModal, setProfileModal] = useState(null)
const filteredRows = useMemo(
() => filterFrameworkPrograms(rows, filters),
[rows, filters]
() => filterFrameworkPrograms(rows, filters, skillSummaries),
[rows, filters, skillSummaries]
)
const filterActive = hasActiveFrameworkImportFilters(filters)
@ -35,22 +39,25 @@ export default function TrainingFrameworkProgramsListPage() {
setLoading(true)
setError('')
try {
const [list, fa, tt, tg] = await Promise.all([
const [list, fa, tt, tg, skills] = await Promise.all([
api.listTrainingFrameworkPrograms(),
api.listFocusAreas({ status: 'active' }),
api.listTrainingTypes({ status: 'active' }),
api.listTargetGroups({ status: 'active' }),
api.listSkillsCatalog({ status: 'active' }),
])
setRows(Array.isArray(list) ? list : [])
setCatalogFocusAreas(Array.isArray(fa) ? fa : [])
setCatalogTrainingTypes(Array.isArray(tt) ? tt : [])
setCatalogTargetGroups(Array.isArray(tg) ? tg : [])
setCatalogSkills(Array.isArray(skills) ? skills : [])
} catch (e) {
setError(e.message || 'Laden fehlgeschlagen')
setRows([])
setCatalogFocusAreas([])
setCatalogTrainingTypes([])
setCatalogTargetGroups([])
setCatalogSkills([])
} finally {
setLoading(false)
}
@ -60,6 +67,29 @@ export default function TrainingFrameworkProgramsListPage() {
load()
}, [load, tenantClubDepKey])
useEffect(() => {
if (!rows.length) {
setSkillSummaries({})
return undefined
}
let cancelled = false
setSummariesLoading(true)
api
.batchSkillProfileSummaries({ frameworkProgramIds: rows.map((r) => r.id) })
.then((data) => {
if (!cancelled) setSkillSummaries(data?.summaries || {})
})
.catch(() => {
if (!cancelled) setSkillSummaries({})
})
.finally(() => {
if (!cancelled) setSummariesLoading(false)
})
return () => {
cancelled = true
}
}, [rows, tenantClubDepKey])
async function handleDelete(id, title) {
if (!confirm(`Rahmenprogramm „${title || id}“ wirklich löschen?`)) return
try {
@ -131,11 +161,11 @@ export default function TrainingFrameworkProgramsListPage() {
programs={rows}
filters={filters}
onFiltersChange={setFilters}
panelOpen={filterPanelOpen}
onPanelOpenChange={setFilterPanelOpen}
catalogFocusAreas={catalogFocusAreas}
catalogTrainingTypes={catalogTrainingTypes}
catalogTargetGroups={catalogTargetGroups}
catalogSkills={catalogSkills}
skillSummaries={skillSummaries}
durationRadioName="fw-list-duration-mode"
className="fw-prog-filter-block--list"
/>
@ -157,6 +187,17 @@ export default function TrainingFrameworkProgramsListPage() {
row={r}
returnContext={frameworkListReturn}
onDelete={handleDelete}
skillSummary={skillSummaries[`framework_program:${r.id}`]}
skillSummaryLoading={summariesLoading}
skillFilterIds={filters.skillIds || []}
skillDisplayLimit={filters.skillDisplayLimit || 10}
onShowSkillProfile={(row) =>
setProfileModal({
artifactType: 'framework_program',
artifactId: row.id,
title: (row.title || '').trim() || `Rahmen #${row.id}`,
})
}
/>
</li>
))}
@ -164,6 +205,14 @@ export default function TrainingFrameworkProgramsListPage() {
)}
</>
)}
<SkillProfileFullModal
open={Boolean(profileModal)}
onClose={() => setProfileModal(null)}
artifactType={profileModal?.artifactType}
artifactId={profileModal?.artifactId}
title={profileModal?.title}
/>
</div>
)
}

View File

@ -3,6 +3,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom'
import api from '../utils/api'
import ExercisePickerModal from '../components/ExercisePickerModal'
import FormActionBar from '../components/FormActionBar'
import SkillProfilePanel from '../components/skills/SkillProfilePanel'
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
import { useAuth } from '../context/AuthContext'
import { useToast } from '../context/ToastContext'
@ -76,6 +77,10 @@ export default function TrainingModuleEditPage() {
const [methods, setMethods] = useState([])
const [pickerOpen, setPickerOpen] = useState(false)
const [error, setError] = useState('')
const [skillProfileData, setSkillProfileData] = useState(null)
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
const [skillProfileError, setSkillProfileError] = useState('')
const [skillProfileTick, setSkillProfileTick] = useState(0)
const [title, setTitle] = useState('')
const [summary, setSummary] = useState('')
@ -130,6 +135,32 @@ export default function TrainingModuleEditPage() {
const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving))
useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving))
useEffect(() => {
if (isNew || !Number.isFinite(moduleId)) {
setSkillProfileData(null)
return undefined
}
let cancelled = false
;(async () => {
setSkillProfileLoading(true)
setSkillProfileError('')
try {
const data = await api.getTrainingModuleSkillProfile(moduleId)
if (!cancelled) setSkillProfileData(data)
} catch (e) {
if (!cancelled) {
setSkillProfileData(null)
setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen')
}
} finally {
if (!cancelled) setSkillProfileLoading(false)
}
})()
return () => {
cancelled = true
}
}, [isNew, moduleId, skillProfileTick])
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
@ -339,6 +370,7 @@ export default function TrainingModuleEditPage() {
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
setBypassDirty(false)
toast.success('Gespeichert.')
setSkillProfileTick((t) => t + 1)
if (closeAfter) goBack()
return true
} catch (err) {
@ -391,6 +423,15 @@ export default function TrainingModuleEditPage() {
onSubmit={handleSave}
>
<div className="page-form-shell__scroll">
{!isNew ? (
<SkillProfilePanel
title="Fähigkeiten im Modul"
profile={skillProfileData?.overall}
loading={skillProfileLoading}
error={skillProfileError}
artifactType="training_module"
/>
) : null}
<div className="form-row">
<label className="form-label">Titel *</label>
<input className="form-input" value={title} onChange={(e) => setTitle(e.target.value)} />

View File

@ -1,9 +1,17 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import api from '../utils/api'
import NavStateLink from '../components/NavStateLink'
import TrainingModulesFilterBlock from '../components/planning/TrainingModulesFilterBlock'
import SkillProfileCompact from '../components/skills/SkillProfileCompact'
import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
import { useAuth } from '../context/AuthContext'
import { getTenantClubDependencyKey } from '../utils/activeClub'
import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext'
import {
EMPTY_TRAINING_MODULE_FILTERS,
filterTrainingModules,
hasActiveTrainingModuleFilters,
} from '../utils/trainingModuleListHelpers'
export default function TrainingModulesListPage() {
const { user } = useAuth()
@ -12,16 +20,32 @@ export default function TrainingModulesListPage() {
const [rows, setRows] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [catalogSkills, setCatalogSkills] = useState([])
const [filters, setFilters] = useState(() => ({ ...EMPTY_TRAINING_MODULE_FILTERS }))
const [skillSummaries, setSkillSummaries] = useState({})
const [summariesLoading, setSummariesLoading] = useState(false)
const [profileModal, setProfileModal] = useState(null)
const filteredRows = useMemo(
() => filterTrainingModules(rows, filters, skillSummaries),
[rows, filters, skillSummaries]
)
const filterActive = hasActiveTrainingModuleFilters(filters)
const load = useCallback(async () => {
setLoading(true)
setError('')
try {
const list = await api.listTrainingModules()
const [list, skills] = await Promise.all([
api.listTrainingModules(),
api.listSkillsCatalog({ status: 'active' }),
])
setRows(Array.isArray(list) ? list : [])
setCatalogSkills(Array.isArray(skills) ? skills : [])
} catch (e) {
setError(e.message || 'Laden fehlgeschlagen')
setRows([])
setCatalogSkills([])
} finally {
setLoading(false)
}
@ -31,6 +55,29 @@ export default function TrainingModulesListPage() {
load()
}, [load, tenantClubDepKey])
useEffect(() => {
if (!rows.length) {
setSkillSummaries({})
return undefined
}
let cancelled = false
setSummariesLoading(true)
api
.batchSkillProfileSummaries({ trainingModuleIds: rows.map((r) => r.id) })
.then((data) => {
if (!cancelled) setSkillSummaries(data?.summaries || {})
})
.catch(() => {
if (!cancelled) setSkillSummaries({})
})
.finally(() => {
if (!cancelled) setSummariesLoading(false)
})
return () => {
cancelled = true
}
}, [rows, tenantClubDepKey])
async function handleDelete(id, title) {
if (!confirm(`Trainingsmodul „${title || id}“ wirklich löschen?`)) return
try {
@ -58,8 +105,8 @@ export default function TrainingModulesListPage() {
Trainingsmodule
</h1>
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}>
Wiederverwendbare Übungsfolgen für die Trainingsplanung. Übernahme in eine Einheit erfolgt dort als
lokale Kopie (mit Herkunftsmarkierung).
Wiederverwendbare Übungsfolgen für die Trainingsplanung. Prozentwerte vergleichen Module nur unter
sichtbaren Modulen.
</p>
</div>
<NavStateLink
@ -82,8 +129,30 @@ export default function TrainingModulesListPage() {
<p style={{ margin: 0, color: 'var(--text2)' }}>Noch keine Module angelegt.</p>
</div>
) : (
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '10px' }}>
{rows.map((r) => (
<>
<TrainingModulesFilterBlock
modules={rows}
filters={filters}
onFiltersChange={setFilters}
catalogSkills={catalogSkills}
skillSummaries={skillSummaries}
className="fw-prog-filter-block--list"
/>
{filteredRows.length === 0 ? (
<div className="card" style={{ padding: '1.25rem' }}>
<h2 style={{ margin: '0 0 0.5rem', fontSize: '1.05rem' }}>Kein Treffer</h2>
<p style={{ margin: 0, color: 'var(--text2)' }}>
{filterActive
? 'Kein Modul passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.'
: 'Keine Einträge.'}
</p>
</div>
) : (
<ul
style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '10px' }}
>
{filteredRows.map((r) => (
<li key={r.id} className="card" style={{ padding: '1rem 1.15rem' }}>
<div
style={{
@ -108,17 +177,43 @@ export default function TrainingModulesListPage() {
>
{(r.title || '').trim() || `Modul #${r.id}`}
</NavStateLink>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
<p
style={{
margin: '0.35rem 0 0',
fontSize: '0.88rem',
color: 'var(--text2)',
lineHeight: 1.45,
}}
>
{(r.summary || '').trim() || '—'}{' '}
<span style={{ color: 'var(--text3)' }}>
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
</span>
</p>
<p style={{ margin: '0.35rem 0 0', fontSize: '0.8rem', color: 'var(--text3)' }}>
Sichtbarkeit: <strong>{r.visibility || '—'}</strong>
</p>
<div style={{ marginTop: '0.5rem' }}>
<SkillProfileCompact
summary={skillSummaries[`training_module:${r.id}`]}
artifactType="training_module"
loading={summariesLoading}
displayLimit={filters.skillDisplayLimit || 12}
highlightSkillIds={filters.skillIds || []}
/>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
<button
type="button"
className="btn btn-secondary btn-small"
onClick={() =>
setProfileModal({
artifactType: 'training_module',
artifactId: r.id,
title: (r.title || '').trim() || `Modul #${r.id}`,
})
}
>
Fähigkeiten-Profil
</button>
<NavStateLink
to={`/planning/training-modules/${r.id}`}
returnContext={modulesListReturn}
@ -137,5 +232,15 @@ export default function TrainingModulesListPage() {
</ul>
)}
</>
)}
<SkillProfileFullModal
open={Boolean(profileModal)}
onClose={() => setProfileModal(null)}
artifactType={profileModal?.artifactType}
artifactId={profileModal?.artifactId}
title={profileModal?.title}
/>
</>
)
}

View File

@ -7,10 +7,12 @@
import { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js'
import * as exercises from '../api/exercises.js'
import * as planning from '../api/planning.js'
import * as skillProfiles from '../api/skillProfiles.js'
export { ACTIVE_CLUB_STORAGE_KEY }
export * from '../api/exercises.js'
export * from '../api/planning.js'
export * from '../api/skillProfiles.js'
// ============================================================================
// Auth
@ -753,6 +755,9 @@ export const api = {
// Training Planning → frontend/src/api/planning.js
...planning,
// Fähigkeiten-Profile & Vorschläge (Phase 3) → frontend/src/api/skillProfiles.js
...skillProfiles,
// Catalogs
listFocusAreas,
createFocusArea,

View File

@ -1,4 +1,9 @@
import { formatDurationDisplay, formatSessionDurationRange } from './trainingDurationUtils'
import {
frameworkSkillSummaryKey,
maxSelectedSkillClubPercent,
summaryHasSkill,
} from './skillProfileListHelpers'
export function frameworkSessionDurationLabel(row) {
return formatSessionDurationRange(row?.session_duration_min, row?.session_duration_max, {
@ -109,6 +114,10 @@ export const EMPTY_FRAMEWORK_IMPORT_FILTERS = {
durationRangeFrom: '',
durationRangeTo: '',
durationPresetMin: null,
skillIds: [],
skillSort: 'title',
skillMinClubPercent: 0,
skillDisplayLimit: 24,
}
export function hasActiveFrameworkImportFilters(filters = {}) {
@ -122,6 +131,9 @@ export function hasActiveFrameworkImportFilters(filters = {}) {
if (String(f.durationRangeTo || '').trim() !== '') return true
}
if (f.durationMode === 'preset' && f.durationPresetMin != null) return true
if ((f.skillIds || []).length) return true
if (Number(f.skillMinClubPercent) > 0) return true
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) return true
return false
}
@ -136,6 +148,17 @@ export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) {
const nameById = (list, id) => list?.find((x) => String(x.id) === String(id))?.name || id
if ((f.skillIds || []).length) {
const names = f.skillIds.map((id) => nameById(catalogs.skills, id))
parts.push(`Fähigkeiten: ${names.join(', ')}`)
}
if (Number(f.skillMinClubPercent) > 0) {
parts.push(`mind. ${f.skillMinClubPercent}% vom Rahmenprogramm-Maximum`)
}
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) {
parts.push('Sortierung: Fähigkeiten-Stärke')
}
if ((f.focusAreaIds || []).length) {
const names = f.focusAreaIds.map((id) => nameById(catalogs.focusAreas, id))
parts.push(`Fokus: ${names.join(', ')}`)
@ -167,14 +190,16 @@ export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) {
/**
* Client-Filter für Rahmenprogramm-Auswahl (Liste / Import-Dialog).
*/
export function filterFrameworkPrograms(rows, filters = {}) {
export function filterFrameworkPrograms(rows, filters = {}, skillSummaries = null) {
const f = { ...EMPTY_FRAMEWORK_IMPORT_FILTERS, ...filters }
const q = (f.query || '').trim().toLowerCase()
const focusIds = new Set((f.focusAreaIds || []).map(String))
const typeIds = new Set((f.trainingTypeIds || []).map(String))
const tgIds = new Set((f.targetGroupIds || []).map(String))
const skillIds = f.skillIds || []
const minClubPct = Number(f.skillMinClubPercent) || 0
return (rows || []).filter((r) => {
let list = (rows || []).filter((r) => {
if (q) {
const blob = [
r.title,
@ -212,6 +237,26 @@ export function filterFrameworkPrograms(rows, filters = {}) {
return true
})
if (skillIds.length && skillSummaries) {
list = list.filter((r) => {
const summary = skillSummaries[frameworkSkillSummaryKey(r.id)]
if (!summary) return false
return skillIds.some((sid) => summaryHasSkill(summary, sid, minClubPct))
})
}
if (f.skillSort === 'skill_strength' && skillIds.length && skillSummaries) {
list = [...list].sort((a, b) => {
const sa = skillSummaries[frameworkSkillSummaryKey(a.id)]
const sb = skillSummaries[frameworkSkillSummaryKey(b.id)]
const pa = maxSelectedSkillClubPercent(sa, skillIds) ?? -1
const pb = maxSelectedSkillClubPercent(sb, skillIds) ?? -1
return pb - pa
})
}
return list
}
export function frameworkProgramOptionLabel(row) {

View File

@ -0,0 +1,137 @@
import { formatDurationDisplay } from './trainingDurationUtils'
import { EMPTY_FRAMEWORK_IMPORT_FILTERS } from './frameworkProgramListHelpers'
import { EMPTY_TRAINING_MODULE_FILTERS } from './trainingModuleListHelpers'
import { peerCorpusCountLabel } from './skillProfileListHelpers'
function nameById(list, id) {
return list?.find((x) => String(x.id) === String(id))?.name || id
}
function peerPercentLabel(artifactType) {
const label = peerCorpusCountLabel(artifactType)
return label === 'Rahmenprogramme' ? 'Rahmenprogramm-Maximum' : `${label}-Maximum`
}
/**
* Entfernbare Filter-Chips (UX wie Übungsliste).
*/
export function buildPlanningArtifactFilterChips({
filters,
setFilters,
catalogs = {},
artifactType = 'framework_program',
emptyFilters = null,
}) {
const base =
emptyFilters ||
(artifactType === 'training_module' ? EMPTY_TRAINING_MODULE_FILTERS : EMPTY_FRAMEWORK_IMPORT_FILTERS)
const f = { ...base, ...filters }
const chips = []
const peerMaxLabel = peerPercentLabel(artifactType)
const q = (f.query || '').trim()
if (q) {
chips.push({
key: 'query',
label: `Suche: „${q}"`,
onRemove: () => setFilters((prev) => ({ ...prev, query: '' })),
})
}
;(f.skillIds || []).forEach((id) => {
chips.push({
key: `skill-${id}`,
label: `Fähigkeit: ${nameById(catalogs.skills, id)}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
skillIds: (prev.skillIds || []).filter((x) => String(x) !== String(id)),
})),
})
})
if (Number(f.skillMinClubPercent) > 0) {
chips.push({
key: 'skill-min-pct',
label: `mind. ${f.skillMinClubPercent}% vom ${peerMaxLabel}`,
onRemove: () => setFilters((prev) => ({ ...prev, skillMinClubPercent: 0 })),
})
}
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) {
chips.push({
key: 'skill-sort',
label: 'Sortierung: Fähigkeiten-Stärke',
onRemove: () => setFilters((prev) => ({ ...prev, skillSort: 'title' })),
})
}
;(f.focusAreaIds || []).forEach((id) => {
chips.push({
key: `focus-${id}`,
label: `Fokus: ${nameById(catalogs.focusAreas, id)}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
focusAreaIds: (prev.focusAreaIds || []).filter((x) => String(x) !== String(id)),
})),
})
})
;(f.trainingTypeIds || []).forEach((id) => {
chips.push({
key: `type-${id}`,
label: `Trainingsart: ${nameById(catalogs.trainingTypes, id)}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
trainingTypeIds: (prev.trainingTypeIds || []).filter((x) => String(x) !== String(id)),
})),
})
})
;(f.targetGroupIds || []).forEach((id) => {
chips.push({
key: `tg-${id}`,
label: `Zielgruppe: ${nameById(catalogs.targetGroups, id)}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
targetGroupIds: (prev.targetGroupIds || []).filter((x) => String(x) !== String(id)),
})),
})
})
if (f.durationMode === 'range') {
const a = String(f.durationRangeFrom || '').trim()
const b = String(f.durationRangeTo || '').trim()
if (a || b) {
const fromLbl = a ? formatDurationDisplay(Number(a), { empty: a }) : '—'
const toLbl = b ? formatDurationDisplay(Number(b), { empty: b }) : '—'
chips.push({
key: 'duration-range',
label: `Dauer: ${fromLbl} ${toLbl}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
durationMode: 'any',
durationRangeFrom: '',
durationRangeTo: '',
})),
})
}
} else if (f.durationMode === 'preset' && f.durationPresetMin != null) {
chips.push({
key: 'duration-preset',
label: `Dauer: ${formatDurationDisplay(f.durationPresetMin)}`,
onRemove: () =>
setFilters((prev) => ({
...prev,
durationMode: 'any',
durationPresetMin: null,
})),
})
}
return chips
}

View File

@ -0,0 +1,110 @@
export function frameworkSkillSummaryKey(id) {
return `framework_program:${id}`
}
export function moduleSkillSummaryKey(id) {
return `training_module:${id}`
}
export function skillEntryFromSummary(summary, skillId) {
if (!summary) return null
const sid = String(skillId)
const fromSkills = (summary.skills || []).find((s) => String(s.skill_id) === sid)
if (fromSkills) return fromSkills
return (summary.top_by_category || []).find((s) => String(s.skill_id) === sid) || null
}
export function summaryHasSkill(summary, skillId, minClubPct = 0) {
const sk = skillEntryFromSummary(summary, skillId)
if (!sk || !(Number(sk.weight) > 0)) return false
const pct = sk.universal_percent
if (pct == null) return minClubPct === 0
return pct >= minClubPct
}
export function maxSelectedSkillClubPercent(summary, skillIds = []) {
if (!summary || !skillIds.length) return null
let max = null
for (const id of skillIds) {
const sk = skillEntryFromSummary(summary, id)
if (!sk) continue
const pct = sk.universal_percent
if (pct == null) continue
if (max == null || pct > max) max = pct
}
return max
}
export function formatClubPercent(value) {
if (value == null || !Number.isFinite(Number(value))) return '—'
const n = Math.min(100, Number(value))
return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%`
}
export function formatSkillWeight(value) {
const n = Number(value)
if (!Number.isFinite(n)) return '—'
return n % 1 === 0 ? String(n) : n.toFixed(1)
}
const PEER_LABELS = {
framework_program: 'Rahmenpr.',
training_module: 'Module',
progression_graph: 'Pfade',
}
const PEER_COUNT_LABELS = {
framework_program: 'Rahmenprogramme',
training_module: 'Module',
progression_graph: 'Regressionspfade',
}
export function peerPercentSuffix(artifactType = 'training_module') {
return PEER_LABELS[artifactType] || 'Peers'
}
export function peerCorpusCountLabel(artifactType = 'training_module') {
return PEER_COUNT_LABELS[artifactType] || 'Planungs-Artefakte'
}
/** KPI-Zeilen: immer Top je Unterkategorie; bei Skill-Filter nur passende Kategorien. */
export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) {
if (!summary) return []
let rows = (summary.top_by_category || []).map((row) => ({
skill_id: row.skill_id,
skill_name: row.skill_name,
category_name: row.category_name,
main_category_name: row.main_category_name,
weight: row.weight ?? row.score,
universal_percent: row.universal_percent,
is_club_best_for_skill: row.is_club_best_for_skill,
}))
if (skillIds.length) {
const wanted = new Set(skillIds.map(String))
rows = rows.filter((row) => wanted.has(String(row.skill_id)))
}
return rows.slice(0, limit)
}
/** @deprecated Nutze kpiRowsFromSummary für Listen */
export function compactSkillDisplayRows(summary, opts = {}) {
return kpiRowsFromSummary(summary, opts)
}
export function artifactTypeLabel(type) {
if (type === 'framework_program') return 'Rahmenprogramm'
if (type === 'training_module') return 'Modul'
if (type === 'progression_graph') return 'Regressionspfad'
return type || 'Artefakt'
}
export function artifactPath(ref) {
if (!ref) return null
if (ref.artifact_type === 'framework_program') {
return `/planning/framework-programs/${ref.artifact_id}`
}
if (ref.artifact_type === 'training_module') {
return `/planning/training-modules/${ref.artifact_id}`
}
return null
}

View File

@ -0,0 +1,57 @@
import {
maxSelectedSkillClubPercent,
moduleSkillSummaryKey,
summaryHasSkill,
} from './skillProfileListHelpers'
export const EMPTY_TRAINING_MODULE_FILTERS = {
query: '',
skillIds: [],
skillSort: 'title',
skillMinClubPercent: 0,
skillDisplayLimit: 12,
}
export function hasActiveTrainingModuleFilters(filters = {}) {
const f = { ...EMPTY_TRAINING_MODULE_FILTERS, ...filters }
if ((f.query || '').trim()) return true
if ((f.skillIds || []).length) return true
if (Number(f.skillMinClubPercent) > 0) return true
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) return true
return false
}
export function filterTrainingModules(rows, filters = {}, skillSummaries = null) {
const f = { ...EMPTY_TRAINING_MODULE_FILTERS, ...filters }
const q = (f.query || '').trim().toLowerCase()
const skillIds = f.skillIds || []
const minClubPct = Number(f.skillMinClubPercent) || 0
let list = (rows || []).filter((r) => {
if (q) {
const blob = [r.title, r.summary].filter(Boolean).join(' ').toLowerCase()
if (!blob.includes(q)) return false
}
return true
})
if (skillIds.length && skillSummaries) {
list = list.filter((r) => {
const summary = skillSummaries[moduleSkillSummaryKey(r.id)]
if (!summary) return false
return skillIds.some((sid) => summaryHasSkill(summary, sid, minClubPct))
})
}
if (f.skillSort === 'skill_strength' && skillIds.length && skillSummaries) {
list = [...list].sort((a, b) => {
const sa = skillSummaries[moduleSkillSummaryKey(a.id)]
const sb = skillSummaries[moduleSkillSummaryKey(b.id)]
const pa = maxSelectedSkillClubPercent(sa, skillIds) ?? -1
const pb = maxSelectedSkillClubPercent(sb, skillIds) ?? -1
return pb - pa
})
}
return list
}