KPI-Scroing, Filter, etc, #43
|
|
@ -58,7 +58,9 @@ Aggregation:
|
||||||
- Summe pro `skill_id` → `weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**)
|
- 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)
|
- `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)
|
- `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 sichtbarer Bibliothek (`compute_corpus_skill_max_weights`, bis 50 Artefakte je Typ)
|
- `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.
|
Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,13 @@ from skill_scoring import (
|
||||||
collect_module_exercise_occurrences,
|
collect_module_exercise_occurrences,
|
||||||
collect_progression_graph_exercise_occurrences,
|
collect_progression_graph_exercise_occurrences,
|
||||||
collect_unit_exercise_occurrences,
|
collect_unit_exercise_occurrences,
|
||||||
|
compute_club_corpus_reference,
|
||||||
compute_corpus_skill_max_weights,
|
compute_corpus_skill_max_weights,
|
||||||
compute_skill_profile,
|
compute_skill_profile,
|
||||||
match_score_for_skill_ids,
|
match_score_for_skill_ids,
|
||||||
profile_for_occurrences,
|
profile_for_occurrences,
|
||||||
|
reference_scale_meta,
|
||||||
|
top_categories_summary,
|
||||||
)
|
)
|
||||||
|
|
||||||
from routers.training_framework_programs import _framework_access
|
from routers.training_framework_programs import _framework_access
|
||||||
|
|
@ -70,12 +73,9 @@ def framework_program_skill_profile(
|
||||||
)
|
)
|
||||||
slots_raw = [r2d(r) for r in cur.fetchall()]
|
slots_raw = [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
ref_max = compute_corpus_skill_max_weights(
|
corpus = _load_club_corpus(cur, tenant)
|
||||||
cur,
|
ref_max = corpus["max_by_skill"]
|
||||||
profile_id=profile_id,
|
ref_by_skill = corpus["ref_by_skill"]
|
||||||
role=role,
|
|
||||||
effective_club_id=tenant.effective_club_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
all_occurrences: List[ExerciseOccurrence] = []
|
all_occurrences: List[ExerciseOccurrence] = []
|
||||||
slot_profiles: List[Dict[str, Any]] = []
|
slot_profiles: List[Dict[str, Any]] = []
|
||||||
|
|
@ -118,14 +118,22 @@ def framework_program_skill_profile(
|
||||||
if all_occurrences
|
if all_occurrences
|
||||||
else _empty_profile()
|
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 {
|
return {
|
||||||
"artifact_type": "framework_program",
|
"artifact_type": "framework_program",
|
||||||
"artifact_id": framework_id,
|
"artifact_id": framework_id,
|
||||||
"artifact_title": row.get("title"),
|
"artifact_title": row.get("title"),
|
||||||
"reference_scale": {
|
"reference_scale": reference_scale_meta(corpus),
|
||||||
"skills_in_corpus": len(ref_max),
|
"club_best_by_skill": {
|
||||||
"description": "universal_percent = Anteil am höchsten Trainingsgewicht dieser Fähigkeit in der sichtbaren Bibliothek",
|
str(k): v for k, v in ref_by_skill.items()
|
||||||
},
|
},
|
||||||
"overall": overall,
|
"overall": overall,
|
||||||
"slots": slot_profiles,
|
"slots": slot_profiles,
|
||||||
|
|
@ -142,24 +150,23 @@ def training_module_skill_profile(
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
row = _module_access(cur, module_id, profile_id, role)
|
row = _module_access(cur, module_id, profile_id, role)
|
||||||
ref_max = compute_corpus_skill_max_weights(
|
corpus = _load_club_corpus(cur, tenant)
|
||||||
cur,
|
ref_max = corpus["max_by_skill"]
|
||||||
profile_id=profile_id,
|
ref_by_skill = corpus["ref_by_skill"]
|
||||||
role=role,
|
|
||||||
effective_club_id=tenant.effective_club_id,
|
|
||||||
)
|
|
||||||
occurrences = collect_module_exercise_occurrences(cur, module_id)
|
occurrences = collect_module_exercise_occurrences(cur, module_id)
|
||||||
overall = (
|
overall = (
|
||||||
profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
|
profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
|
||||||
if occurrences
|
if occurrences
|
||||||
else _empty_profile()
|
else _empty_profile()
|
||||||
)
|
)
|
||||||
|
_enrich_profile_club_best(overall, ref_by_skill, "training_module", module_id)
|
||||||
return {
|
return {
|
||||||
"artifact_type": "training_module",
|
"artifact_type": "training_module",
|
||||||
"artifact_id": module_id,
|
"artifact_id": module_id,
|
||||||
"artifact_title": row.get("title"),
|
"artifact_title": row.get("title"),
|
||||||
"reference_scale": {
|
"reference_scale": reference_scale_meta(corpus),
|
||||||
"skills_in_corpus": len(ref_max),
|
"club_best_by_skill": {
|
||||||
|
str(k): v for k, v in ref_by_skill.items()
|
||||||
},
|
},
|
||||||
"overall": overall,
|
"overall": overall,
|
||||||
}
|
}
|
||||||
|
|
@ -175,12 +182,9 @@ def progression_graph_skill_profile(
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
row = _require_graph_read(cur, graph_id, profile_id, role)
|
row = _require_graph_read(cur, graph_id, profile_id, role)
|
||||||
ref_max = compute_corpus_skill_max_weights(
|
corpus = _load_club_corpus(cur, tenant)
|
||||||
cur,
|
ref_max = corpus["max_by_skill"]
|
||||||
profile_id=profile_id,
|
ref_by_skill = corpus["ref_by_skill"]
|
||||||
role=role,
|
|
||||||
effective_club_id=tenant.effective_club_id,
|
|
||||||
)
|
|
||||||
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
|
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
|
||||||
overall = (
|
overall = (
|
||||||
profile_for_occurrences(
|
profile_for_occurrences(
|
||||||
|
|
@ -192,17 +196,96 @@ def progression_graph_skill_profile(
|
||||||
if occurrences
|
if occurrences
|
||||||
else _empty_profile()
|
else _empty_profile()
|
||||||
)
|
)
|
||||||
|
_enrich_profile_club_best(overall, ref_by_skill, "progression_graph", graph_id)
|
||||||
return {
|
return {
|
||||||
"artifact_type": "progression_graph",
|
"artifact_type": "progression_graph",
|
||||||
"artifact_id": graph_id,
|
"artifact_id": graph_id,
|
||||||
"artifact_title": row.get("name"),
|
"artifact_title": row.get("name"),
|
||||||
"reference_scale": {
|
"reference_scale": reference_scale_meta(corpus),
|
||||||
"skills_in_corpus": len(ref_max),
|
"club_best_by_skill": {
|
||||||
|
str(k): v for k, v in ref_by_skill.items()
|
||||||
},
|
},
|
||||||
"overall": overall,
|
"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)
|
||||||
|
corpus = compute_club_corpus_reference(
|
||||||
|
cur,
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
effective_club_id=tenant.effective_club_id,
|
||||||
|
include_artifact_summaries=True,
|
||||||
|
)
|
||||||
|
ref_by_skill = corpus["ref_by_skill"]
|
||||||
|
all_summaries = corpus.get("artifact_summaries") or {}
|
||||||
|
|
||||||
|
if fp_ids:
|
||||||
|
allowed_fp = []
|
||||||
|
for fid in fp_ids:
|
||||||
|
try:
|
||||||
|
_framework_access(cur, fid, profile_id, role)
|
||||||
|
allowed_fp.append(fid)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
for fid in allowed_fp:
|
||||||
|
key = f"framework_program:{fid}"
|
||||||
|
if key in all_summaries:
|
||||||
|
summaries[key] = all_summaries[key]
|
||||||
|
|
||||||
|
if mod_ids:
|
||||||
|
allowed_mod = []
|
||||||
|
for mid in mod_ids:
|
||||||
|
try:
|
||||||
|
_module_access(cur, mid, profile_id, role)
|
||||||
|
allowed_mod.append(mid)
|
||||||
|
except HTTPException:
|
||||||
|
pass
|
||||||
|
for mid in allowed_mod:
|
||||||
|
key = f"training_module:{mid}"
|
||||||
|
if key in all_summaries:
|
||||||
|
summaries[key] = all_summaries[key]
|
||||||
|
|
||||||
|
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": reference_scale_meta(corpus),
|
||||||
|
"club_best_by_skill": club_best_subset,
|
||||||
|
"summaries": summaries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/skill-discovery/suggestions")
|
@router.get("/skill-discovery/suggestions")
|
||||||
def skill_discovery_suggestions(
|
def skill_discovery_suggestions(
|
||||||
skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
|
skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
|
||||||
|
|
@ -278,7 +361,7 @@ def skill_discovery_suggestions(
|
||||||
"match": match,
|
"match": match,
|
||||||
"skill_profile_summary": {
|
"skill_profile_summary": {
|
||||||
"total_score": prof.get("total_score"),
|
"total_score": prof.get("total_score"),
|
||||||
"top_by_category": _top_categories_summary(prof),
|
"top_by_category": top_categories_summary(prof),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -322,7 +405,7 @@ def skill_discovery_suggestions(
|
||||||
"match": match,
|
"match": match,
|
||||||
"skill_profile_summary": {
|
"skill_profile_summary": {
|
||||||
"total_score": prof.get("total_score"),
|
"total_score": prof.get("total_score"),
|
||||||
"top_by_category": _top_categories_summary(prof),
|
"top_by_category": top_categories_summary(prof),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -368,7 +451,7 @@ def skill_discovery_suggestions(
|
||||||
"match": match,
|
"match": match,
|
||||||
"skill_profile_summary": {
|
"skill_profile_summary": {
|
||||||
"total_score": prof.get("total_score"),
|
"total_score": prof.get("total_score"),
|
||||||
"top_by_category": _top_categories_summary(prof),
|
"top_by_category": top_categories_summary(prof),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -405,5 +488,66 @@ def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dic
|
||||||
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_club_corpus(cur, tenant: TenantContext) -> Dict[str, Any]:
|
||||||
|
return compute_club_corpus_reference(
|
||||||
|
cur,
|
||||||
|
profile_id=tenant.profile_id,
|
||||||
|
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]:
|
def _empty_profile() -> Dict[str, Any]:
|
||||||
return compute_skill_profile([], {})
|
return compute_skill_profile([], {})
|
||||||
|
|
|
||||||
|
|
@ -471,96 +471,298 @@ def merge_skill_weights_into_max(
|
||||||
target[sid] = w
|
target[sid] = w
|
||||||
|
|
||||||
|
|
||||||
def compute_corpus_skill_max_weights(
|
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"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
if ref > 0:
|
||||||
|
top["universal_percent"] = _round2(float(top["weight"]) / ref * 100.0)
|
||||||
|
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 = 20,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Leichtgewichtiges Profil für Listen — ohne Übungsdetails."""
|
||||||
|
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"),
|
||||||
|
"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
|
||||||
|
skills_out.append(entry)
|
||||||
|
if 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),
|
||||||
|
"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 compute_club_corpus_reference(
|
||||||
cur,
|
cur,
|
||||||
*,
|
*,
|
||||||
profile_id: int,
|
profile_id: int,
|
||||||
role: Optional[str],
|
|
||||||
effective_club_id: Optional[int],
|
effective_club_id: Optional[int],
|
||||||
limit_per_type: int = 50,
|
include_artifact_summaries: bool = False,
|
||||||
) -> Dict[int, float]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Maximum absolutes Trainingsgewicht je Fähigkeit über sichtbare Bibliotheksartefakte.
|
Stärkstes Trainingsgewicht je Fähigkeit über alle Vereins-Artefakte (sichtbar im aktiven Verein).
|
||||||
Basis für universal_percent (Skala über alle Programme).
|
Optional: kompakte Profile aller gescannten Artefakte (ein Durchlauf für Listen).
|
||||||
"""
|
"""
|
||||||
from tenant_context import library_content_visibility_sql
|
from tenant_context import club_library_visibility_sql
|
||||||
|
|
||||||
max_by_skill: Dict[int, float] = {}
|
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 scan_frameworks():
|
if effective_club_id is None:
|
||||||
vis_clause, vis_params = library_content_visibility_sql(
|
return {
|
||||||
|
"club_id": None,
|
||||||
|
"max_by_skill": max_by_skill,
|
||||||
|
"ref_by_skill": ref_by_skill,
|
||||||
|
"artifact_count": 0,
|
||||||
|
"artifact_summaries": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
def ingest(artifact_type: str, artifact_id: 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=artifact_id,
|
||||||
|
artifact_title=title,
|
||||||
|
)
|
||||||
|
if include_artifact_summaries:
|
||||||
|
raw_profiles[f"{artifact_type}:{artifact_id}"] = prof
|
||||||
|
|
||||||
|
vis_clause, vis_params = club_library_visibility_sql(
|
||||||
alias="fp",
|
alias="fp",
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
role=role,
|
|
||||||
effective_club_id=effective_club_id,
|
effective_club_id=effective_club_id,
|
||||||
)
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT fp.id FROM training_framework_programs fp
|
SELECT fp.id, fp.title
|
||||||
|
FROM training_framework_programs fp
|
||||||
WHERE ({vis_clause})
|
WHERE ({vis_clause})
|
||||||
ORDER BY fp.updated_at DESC NULLS LAST
|
ORDER BY fp.updated_at DESC NULLS LAST
|
||||||
LIMIT %s
|
|
||||||
""",
|
""",
|
||||||
(*vis_params, limit_per_type),
|
vis_params,
|
||||||
)
|
)
|
||||||
for row in cur.fetchall():
|
fw_rows = cur.fetchall()
|
||||||
fid = int(row["id"])
|
if fw_rows:
|
||||||
cur.execute(
|
fw_ids = [int(r["id"]) for r in fw_rows]
|
||||||
"""
|
titles = {int(r["id"]): r.get("title") for r in fw_rows}
|
||||||
SELECT tu.id
|
occ_map = batch_framework_occurrences_by_id(cur, fw_ids)
|
||||||
FROM training_framework_slots s
|
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
|
||||||
INNER JOIN training_units tu ON tu.framework_slot_id = s.id
|
skills_map = fetch_exercise_skills_bulk(cur, all_eids)
|
||||||
WHERE s.framework_program_id = %s
|
profiles = batch_compute_profiles(occ_map, skills_map)
|
||||||
""",
|
for fid, prof in profiles.items():
|
||||||
(fid,),
|
ingest("framework_program", fid, titles.get(fid), prof)
|
||||||
)
|
|
||||||
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)
|
|
||||||
merge_skill_weights_into_max(max_by_skill, prof)
|
|
||||||
|
|
||||||
def scan_modules():
|
vis_clause, vis_params = club_library_visibility_sql(
|
||||||
vis_clause, vis_params = library_content_visibility_sql(
|
|
||||||
alias="m",
|
alias="m",
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
role=role,
|
|
||||||
effective_club_id=effective_club_id,
|
effective_club_id=effective_club_id,
|
||||||
)
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT m.id FROM training_modules m
|
SELECT m.id, m.title
|
||||||
|
FROM training_modules m
|
||||||
WHERE ({vis_clause})
|
WHERE ({vis_clause})
|
||||||
ORDER BY m.updated_at DESC NULLS LAST
|
ORDER BY m.updated_at DESC NULLS LAST
|
||||||
LIMIT %s
|
|
||||||
""",
|
""",
|
||||||
(*vis_params, limit_per_type),
|
vis_params,
|
||||||
)
|
)
|
||||||
for row in cur.fetchall():
|
mod_rows = cur.fetchall()
|
||||||
mid = int(row["id"])
|
if mod_rows:
|
||||||
occ = collect_module_exercise_occurrences(cur, mid)
|
mod_ids = [int(r["id"]) for r in mod_rows]
|
||||||
if not occ:
|
titles = {int(r["id"]): r.get("title") for r in mod_rows}
|
||||||
continue
|
occ_map = batch_module_occurrences_by_id(cur, mod_ids)
|
||||||
prof = profile_for_occurrences(cur, occ)
|
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
|
||||||
merge_skill_weights_into_max(max_by_skill, prof)
|
skills_map = fetch_exercise_skills_bulk(cur, all_eids)
|
||||||
|
profiles = batch_compute_profiles(occ_map, skills_map)
|
||||||
|
for mid, prof in profiles.items():
|
||||||
|
ingest("training_module", mid, titles.get(mid), prof)
|
||||||
|
|
||||||
def scan_graphs():
|
vis_clause, vis_params = club_library_visibility_sql(
|
||||||
vis_clause, vis_params = library_content_visibility_sql(
|
|
||||||
alias="g",
|
alias="g",
|
||||||
profile_id=profile_id,
|
profile_id=profile_id,
|
||||||
role=role,
|
|
||||||
effective_club_id=effective_club_id,
|
effective_club_id=effective_club_id,
|
||||||
)
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT g.id FROM exercise_progression_graphs g
|
SELECT g.id, g.name
|
||||||
|
FROM exercise_progression_graphs g
|
||||||
WHERE ({vis_clause})
|
WHERE ({vis_clause})
|
||||||
ORDER BY g.updated_at DESC NULLS LAST
|
ORDER BY g.updated_at DESC NULLS LAST
|
||||||
LIMIT %s
|
|
||||||
""",
|
""",
|
||||||
(*vis_params, limit_per_type),
|
vis_params,
|
||||||
)
|
)
|
||||||
for row in cur.fetchall():
|
for row in cur.fetchall():
|
||||||
gid = int(row["id"])
|
gid = int(row["id"])
|
||||||
|
|
@ -570,12 +772,55 @@ def compute_corpus_skill_max_weights(
|
||||||
prof = profile_for_occurrences(
|
prof = profile_for_occurrences(
|
||||||
cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
|
cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
|
||||||
)
|
)
|
||||||
merge_skill_weights_into_max(max_by_skill, prof)
|
ingest("progression_graph", gid, row.get("name"), prof)
|
||||||
|
|
||||||
scan_frameworks()
|
artifact_summaries: Dict[str, Dict[str, Any]] = {}
|
||||||
scan_modules()
|
if include_artifact_summaries and raw_profiles:
|
||||||
scan_graphs()
|
for key, prof in raw_profiles.items():
|
||||||
return max_by_skill
|
_apply_reference_to_profile(prof, max_by_skill)
|
||||||
|
artifact_summaries[key] = compact_profile_summary(prof, ref_by_skill)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"club_id": effective_club_id,
|
||||||
|
"max_by_skill": max_by_skill,
|
||||||
|
"ref_by_skill": ref_by_skill,
|
||||||
|
"artifact_count": artifact_count,
|
||||||
|
"artifact_summaries": artifact_summaries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reference_scale_meta(corpus: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"scope": "club",
|
||||||
|
"club_id": corpus.get("club_id"),
|
||||||
|
"skills_in_corpus": len(corpus.get("max_by_skill") or {}),
|
||||||
|
"artifacts_scanned": corpus.get("artifact_count") or 0,
|
||||||
|
"description": (
|
||||||
|
"universal_percent = Anteil am stärksten genutzten Vereins-Artefakt je Fähigkeit "
|
||||||
|
"(nur visibility=club im aktiven Verein)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_corpus_skill_max_weights(
|
||||||
|
cur,
|
||||||
|
*,
|
||||||
|
profile_id: int,
|
||||||
|
role: Optional[str],
|
||||||
|
effective_club_id: Optional[int],
|
||||||
|
limit_per_type: int = 50,
|
||||||
|
) -> Dict[int, float]:
|
||||||
|
"""
|
||||||
|
Vereins-Referenz je Fähigkeit (Legacy-Hülle — nutzt compute_club_corpus_reference).
|
||||||
|
role/limit_per_type werden ignoriert (Vereinskontext, alle Vereins-Artefakte).
|
||||||
|
"""
|
||||||
|
del role, limit_per_type
|
||||||
|
corpus = compute_club_corpus_reference(
|
||||||
|
cur,
|
||||||
|
profile_id=profile_id,
|
||||||
|
effective_club_id=effective_club_id,
|
||||||
|
)
|
||||||
|
return corpus["max_by_skill"]
|
||||||
|
|
||||||
|
|
||||||
def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:
|
def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,33 @@ def library_content_visibility_sql(
|
||||||
return "(" + " OR ".join(parts) + ")", params
|
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
|
@dataclass
|
||||||
class TenantContext:
|
class TenantContext:
|
||||||
profile_id: int
|
profile_id: int
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,17 @@ export async function getSkillDiscoverySuggestions(skillIds, opts = {}) {
|
||||||
if (opts.limit != null) params.set('limit', String(opts.limit))
|
if (opts.limit != null) params.set('limit', String(opts.limit))
|
||||||
return request(`/api/skill-discovery/suggestions?${params.toString()}`)
|
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 || [],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2690,6 +2690,107 @@ html.modal-scroll-locked .app-main {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skill-profile-compact {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.skill-profile-compact__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-compact__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.skill-profile-compact__item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.skill-profile-compact__name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--text1);
|
||||||
|
}
|
||||||
|
.skill-profile-compact__metric {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
.skill-profile-compact__best {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.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 {
|
.skill-discovery {
|
||||||
padding: 1.15rem 1.25rem;
|
padding: 1.15rem 1.25rem;
|
||||||
max-width: 52rem;
|
max-width: 52rem;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import NavStateLink from '../NavStateLink'
|
import NavStateLink from '../NavStateLink'
|
||||||
|
import SkillProfileCompact from '../skills/SkillProfileCompact'
|
||||||
import {
|
import {
|
||||||
frameworkSessionDurationLabel,
|
frameworkSessionDurationLabel,
|
||||||
splitFrameworkCommaAgg,
|
splitFrameworkCommaAgg,
|
||||||
|
|
@ -26,7 +27,16 @@ function CatalogGroup({ label, items, variant }) {
|
||||||
/**
|
/**
|
||||||
* Einzelkarte für die Rahmenprogramm-Bibliothek.
|
* 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 title = (row.title || '').trim() || `Rahmen #${row.id}`
|
||||||
const description = (row.description || '').trim()
|
const description = (row.description || '').trim()
|
||||||
const durationLabel = frameworkSessionDurationLabel(row)
|
const durationLabel = frameworkSessionDurationLabel(row)
|
||||||
|
|
@ -112,6 +122,27 @@ export default function FrameworkProgramListCard({ row, returnContext, onDelete
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : 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)}
|
||||||
|
>
|
||||||
|
Vollständiges Profil
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<SkillProfileCompact
|
||||||
|
summary={skillSummary}
|
||||||
|
skillIds={skillFilterIds}
|
||||||
|
loading={skillSummaryLoading}
|
||||||
|
displayLimit={skillDisplayLimit}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<footer className="fw-prog-card__actions">
|
<footer className="fw-prog-card__actions">
|
||||||
<NavStateLink
|
<NavStateLink
|
||||||
to={`/planning/framework-programs/${row.id}`}
|
to={`/planning/framework-programs/${row.id}`}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ export default function FrameworkProgramsFilterBlock({
|
||||||
catalogFocusAreas = [],
|
catalogFocusAreas = [],
|
||||||
catalogTrainingTypes = [],
|
catalogTrainingTypes = [],
|
||||||
catalogTargetGroups = [],
|
catalogTargetGroups = [],
|
||||||
|
catalogSkills = [],
|
||||||
|
skillSummaries = null,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
durationRadioName = 'fw-duration-mode',
|
durationRadioName = 'fw-duration-mode',
|
||||||
showHint = true,
|
showHint = true,
|
||||||
|
|
@ -31,8 +33,8 @@ export default function FrameworkProgramsFilterBlock({
|
||||||
)
|
)
|
||||||
|
|
||||||
const matchCount = useMemo(
|
const matchCount = useMemo(
|
||||||
() => filterFrameworkPrograms(programs, filters).length,
|
() => filterFrameworkPrograms(programs, filters, skillSummaries).length,
|
||||||
[programs, filters]
|
[programs, filters, skillSummaries]
|
||||||
)
|
)
|
||||||
|
|
||||||
const totalCount = (programs || []).length
|
const totalCount = (programs || []).length
|
||||||
|
|
@ -43,8 +45,9 @@ export default function FrameworkProgramsFilterBlock({
|
||||||
focusAreas: catalogFocusAreas,
|
focusAreas: catalogFocusAreas,
|
||||||
trainingTypes: catalogTrainingTypes,
|
trainingTypes: catalogTrainingTypes,
|
||||||
targetGroups: catalogTargetGroups,
|
targetGroups: catalogTargetGroups,
|
||||||
|
skills: catalogSkills,
|
||||||
}),
|
}),
|
||||||
[filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups]
|
[filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups, catalogSkills]
|
||||||
)
|
)
|
||||||
|
|
||||||
const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
|
const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
|
||||||
|
|
@ -285,6 +288,61 @@ export default function FrameworkProgramsFilterBlock({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{catalogSkills.length > 0 ? (
|
||||||
|
<div className="fw-import-catalog-block fw-import-catalog-block--skills">
|
||||||
|
<span className="form-label">Fähigkeiten (Vereinsvergleich)</span>
|
||||||
|
<p className="form-sub" style={{ margin: '0 0 8px' }}>
|
||||||
|
Filtert nach Trainingsgewicht relativ zum stärksten Vereins-Programm je Fähigkeit — ohne
|
||||||
|
Punktewerte eingeben.
|
||||||
|
</p>
|
||||||
|
<div className="framework-catalog-checkgrid fw-import-skill-grid">
|
||||||
|
{catalogSkills.map((sk) => (
|
||||||
|
<label key={sk.id} className="framework-catalog-check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={(filters.skillIds || []).includes(String(sk.id))}
|
||||||
|
onChange={() => toggleId('skillIds', sk.id)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<span>{sk.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{(filters.skillIds || []).length > 0 ? (
|
||||||
|
<div className="fw-import-skill-options">
|
||||||
|
<div className="form-row" style={{ marginBottom: 8 }}>
|
||||||
|
<label className="form-label">Mindest-Anteil am Vereins-Maximum</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={String(filters.skillMinClubPercent ?? 0)}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateFilter({ skillMinClubPercent: 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={filters.skillSort || 'title'}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => updateFilter({ skillSort: 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>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{showHint ? (
|
{showHint ? (
|
||||||
<p className="form-sub fw-import-filter-panel__hint">
|
<p className="form-sub fw-import-filter-panel__hint">
|
||||||
|
|
|
||||||
67
frontend/src/components/skills/SkillProfileCompact.jsx
Normal file
67
frontend/src/components/skills/SkillProfileCompact.jsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {
|
||||||
|
artifactPath,
|
||||||
|
artifactTypeLabel,
|
||||||
|
compactSkillDisplayRows,
|
||||||
|
formatClubPercent,
|
||||||
|
} from '../../utils/skillProfileListHelpers'
|
||||||
|
|
||||||
|
function formatWeight(value) {
|
||||||
|
const n = Number(value)
|
||||||
|
if (!Number.isFinite(n)) return '0'
|
||||||
|
return n % 1 === 0 ? String(n) : n.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompakte Fähigkeiten-Zeile für Listen/Kacheln.
|
||||||
|
*/
|
||||||
|
export default function SkillProfileCompact({
|
||||||
|
summary,
|
||||||
|
skillIds = [],
|
||||||
|
loading = false,
|
||||||
|
emptyText = 'Noch keine Übungen mit Fähigkeiten',
|
||||||
|
displayLimit = 6,
|
||||||
|
showClubBest = true,
|
||||||
|
}) {
|
||||||
|
if (loading) {
|
||||||
|
return <p className="skill-profile-compact skill-profile-compact--loading form-sub">Fähigkeiten werden berechnet…</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = compactSkillDisplayRows(summary, { skillIds, limit: displayLimit })
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return summary ? (
|
||||||
|
<p className="skill-profile-compact skill-profile-compact--empty form-sub">{emptyText}</p>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="skill-profile-compact">
|
||||||
|
<span className="skill-profile-compact__label">Fähigkeiten</span>
|
||||||
|
<ul className="skill-profile-compact__list">
|
||||||
|
{rows.map((sk) => {
|
||||||
|
const best = sk.club_best
|
||||||
|
const path = showClubBest && best ? artifactPath(best) : null
|
||||||
|
return (
|
||||||
|
<li key={sk.skill_id} className="skill-profile-compact__item">
|
||||||
|
<span className="skill-profile-compact__name" title={sk.skill_name}>
|
||||||
|
{sk.skill_name}
|
||||||
|
</span>
|
||||||
|
<span className="skill-profile-compact__metric" title="Trainingsgewicht und Anteil am Vereins-Maximum">
|
||||||
|
{formatWeight(sk.weight)} · {formatClubPercent(sk.universal_percent)} Verein
|
||||||
|
</span>
|
||||||
|
{showClubBest && best && path && sk.universal_percent != null && sk.universal_percent < 100 ? (
|
||||||
|
<span className="skill-profile-compact__best form-sub">
|
||||||
|
Vereins-Top: {artifactTypeLabel(best.artifact_type)} „{best.artifact_title || best.artifact_id}“ (
|
||||||
|
{formatWeight(best.weight)})
|
||||||
|
</span>
|
||||||
|
) : sk.universal_percent >= 100 ? (
|
||||||
|
<span className="skill-profile-compact__best form-sub">Stärkste Vereins-Nutzung dieser Fähigkeit</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
frontend/src/components/skills/SkillProfileFullModal.jsx
Normal file
77
frontend/src/components/skills/SkillProfileFullModal.jsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import api from '../../utils/api'
|
||||||
|
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
|
||||||
|
|
||||||
|
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"
|
||||||
|
defaultExpanded
|
||||||
|
/>
|
||||||
|
{data?.reference_scale?.scope === 'club' && !loading ? (
|
||||||
|
<p className="form-sub skill-profile-modal__scale">
|
||||||
|
Vergleichsbasis: {data.reference_scale.artifacts_scanned ?? 0} Vereins-Artefakte (
|
||||||
|
{data.reference_scale.skills_in_corpus ?? 0} Fähigkeiten mit Referenz).
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ function barFillPercent(skill, maxWeight, hasReferenceScale) {
|
||||||
|
|
||||||
function metricLabel(skill, hasReferenceScale) {
|
function metricLabel(skill, hasReferenceScale) {
|
||||||
if (hasReferenceScale && skill?.universal_percent != null) {
|
if (hasReferenceScale && skill?.universal_percent != null) {
|
||||||
return `${skill.universal_percent}% Bibliothek`
|
return `${skill.universal_percent}% vom Vereins-Maximum`
|
||||||
}
|
}
|
||||||
return formatWeight(skillWeight(skill))
|
return formatWeight(skillWeight(skill))
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +43,14 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
|
||||||
</div>
|
</div>
|
||||||
{hasReferenceScale ? (
|
{hasReferenceScale ? (
|
||||||
<span className="skill-profile__meta-hint">
|
<span className="skill-profile__meta-hint">
|
||||||
Absolut: {formatWeight(skillWeight(skill))} · Skala über alle Programme
|
Trainingsgewicht {formatWeight(skillWeight(skill))}
|
||||||
|
{skill.club_best ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
· Vereins-Top: {skill.club_best.artifact_title || skill.club_best.artifact_id} (
|
||||||
|
{formatWeight(skill.club_best.weight)})
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -111,7 +118,7 @@ export default function SkillProfilePanel({
|
||||||
loading = false,
|
loading = false,
|
||||||
error = '',
|
error = '',
|
||||||
title = 'Fähigkeiten-Profil',
|
title = 'Fähigkeiten-Profil',
|
||||||
hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Intensität, Stufen von/bis). Trainingsgewicht ist über Programme vergleichbar; die Balken zeigen die Stärke relativ zur Bibliothek.',
|
hint = 'Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Vergleich nur im aktiven Verein (visibility=club). Pro Kategorie die stärkste Fähigkeit; Balken = Anteil am Vereins-Maximum dieser Fähigkeit.',
|
||||||
defaultExpanded = true,
|
defaultExpanded = true,
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import api from '../utils/api'
|
||||||
import NavStateLink from '../components/NavStateLink'
|
import NavStateLink from '../components/NavStateLink'
|
||||||
import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock'
|
import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock'
|
||||||
import FrameworkProgramListCard from '../components/planning/FrameworkProgramListCard'
|
import FrameworkProgramListCard from '../components/planning/FrameworkProgramListCard'
|
||||||
|
import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
|
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
|
||||||
|
|
@ -22,12 +23,16 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
const [catalogFocusAreas, setCatalogFocusAreas] = useState([])
|
const [catalogFocusAreas, setCatalogFocusAreas] = useState([])
|
||||||
const [catalogTrainingTypes, setCatalogTrainingTypes] = useState([])
|
const [catalogTrainingTypes, setCatalogTrainingTypes] = useState([])
|
||||||
const [catalogTargetGroups, setCatalogTargetGroups] = useState([])
|
const [catalogTargetGroups, setCatalogTargetGroups] = useState([])
|
||||||
|
const [catalogSkills, setCatalogSkills] = useState([])
|
||||||
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
|
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
|
||||||
const [filterPanelOpen, setFilterPanelOpen] = useState(true)
|
const [filterPanelOpen, setFilterPanelOpen] = useState(true)
|
||||||
|
const [skillSummaries, setSkillSummaries] = useState({})
|
||||||
|
const [summariesLoading, setSummariesLoading] = useState(false)
|
||||||
|
const [profileModal, setProfileModal] = useState(null)
|
||||||
|
|
||||||
const filteredRows = useMemo(
|
const filteredRows = useMemo(
|
||||||
() => filterFrameworkPrograms(rows, filters),
|
() => filterFrameworkPrograms(rows, filters, skillSummaries),
|
||||||
[rows, filters]
|
[rows, filters, skillSummaries]
|
||||||
)
|
)
|
||||||
const filterActive = hasActiveFrameworkImportFilters(filters)
|
const filterActive = hasActiveFrameworkImportFilters(filters)
|
||||||
|
|
||||||
|
|
@ -35,22 +40,25 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const [list, fa, tt, tg] = await Promise.all([
|
const [list, fa, tt, tg, skills] = await Promise.all([
|
||||||
api.listTrainingFrameworkPrograms(),
|
api.listTrainingFrameworkPrograms(),
|
||||||
api.listFocusAreas({ status: 'active' }),
|
api.listFocusAreas({ status: 'active' }),
|
||||||
api.listTrainingTypes({ status: 'active' }),
|
api.listTrainingTypes({ status: 'active' }),
|
||||||
api.listTargetGroups({ status: 'active' }),
|
api.listTargetGroups({ status: 'active' }),
|
||||||
|
api.listSkills({ status: 'active' }),
|
||||||
])
|
])
|
||||||
setRows(Array.isArray(list) ? list : [])
|
setRows(Array.isArray(list) ? list : [])
|
||||||
setCatalogFocusAreas(Array.isArray(fa) ? fa : [])
|
setCatalogFocusAreas(Array.isArray(fa) ? fa : [])
|
||||||
setCatalogTrainingTypes(Array.isArray(tt) ? tt : [])
|
setCatalogTrainingTypes(Array.isArray(tt) ? tt : [])
|
||||||
setCatalogTargetGroups(Array.isArray(tg) ? tg : [])
|
setCatalogTargetGroups(Array.isArray(tg) ? tg : [])
|
||||||
|
setCatalogSkills(Array.isArray(skills) ? skills : [])
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || 'Laden fehlgeschlagen')
|
setError(e.message || 'Laden fehlgeschlagen')
|
||||||
setRows([])
|
setRows([])
|
||||||
setCatalogFocusAreas([])
|
setCatalogFocusAreas([])
|
||||||
setCatalogTrainingTypes([])
|
setCatalogTrainingTypes([])
|
||||||
setCatalogTargetGroups([])
|
setCatalogTargetGroups([])
|
||||||
|
setCatalogSkills([])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
@ -60,6 +68,29 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
load()
|
load()
|
||||||
}, [load, tenantClubDepKey])
|
}, [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) {
|
async function handleDelete(id, title) {
|
||||||
if (!confirm(`Rahmenprogramm „${title || id}“ wirklich löschen?`)) return
|
if (!confirm(`Rahmenprogramm „${title || id}“ wirklich löschen?`)) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -136,6 +167,8 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
catalogFocusAreas={catalogFocusAreas}
|
catalogFocusAreas={catalogFocusAreas}
|
||||||
catalogTrainingTypes={catalogTrainingTypes}
|
catalogTrainingTypes={catalogTrainingTypes}
|
||||||
catalogTargetGroups={catalogTargetGroups}
|
catalogTargetGroups={catalogTargetGroups}
|
||||||
|
catalogSkills={catalogSkills}
|
||||||
|
skillSummaries={skillSummaries}
|
||||||
durationRadioName="fw-list-duration-mode"
|
durationRadioName="fw-list-duration-mode"
|
||||||
className="fw-prog-filter-block--list"
|
className="fw-prog-filter-block--list"
|
||||||
/>
|
/>
|
||||||
|
|
@ -157,6 +190,17 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
row={r}
|
row={r}
|
||||||
returnContext={frameworkListReturn}
|
returnContext={frameworkListReturn}
|
||||||
onDelete={handleDelete}
|
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>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|
@ -164,6 +208,14 @@ export default function TrainingFrameworkProgramsListPage() {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SkillProfileFullModal
|
||||||
|
open={Boolean(profileModal)}
|
||||||
|
onClose={() => setProfileModal(null)}
|
||||||
|
artifactType={profileModal?.artifactType}
|
||||||
|
artifactId={profileModal?.artifactId}
|
||||||
|
title={profileModal?.title}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import NavStateLink from '../components/NavStateLink'
|
import NavStateLink from '../components/NavStateLink'
|
||||||
|
import SkillProfileCompact from '../components/skills/SkillProfileCompact'
|
||||||
|
import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||||
import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext'
|
import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext'
|
||||||
|
|
@ -12,6 +14,9 @@ export default function TrainingModulesListPage() {
|
||||||
const [rows, setRows] = useState([])
|
const [rows, setRows] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [skillSummaries, setSkillSummaries] = useState({})
|
||||||
|
const [summariesLoading, setSummariesLoading] = useState(false)
|
||||||
|
const [profileModal, setProfileModal] = useState(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -31,6 +36,29 @@ export default function TrainingModulesListPage() {
|
||||||
load()
|
load()
|
||||||
}, [load, tenantClubDepKey])
|
}, [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) {
|
async function handleDelete(id, title) {
|
||||||
if (!confirm(`Trainingsmodul „${title || id}“ wirklich löschen?`)) return
|
if (!confirm(`Trainingsmodul „${title || id}“ wirklich löschen?`)) return
|
||||||
try {
|
try {
|
||||||
|
|
@ -58,8 +86,7 @@ export default function TrainingModulesListPage() {
|
||||||
Trainingsmodule
|
Trainingsmodule
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}>
|
<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
|
Wiederverwendbare Übungsfolgen für die Trainingsplanung. Fähigkeiten werden im Vereinskontext verglichen.
|
||||||
lokale Kopie (mit Herkunftsmarkierung).
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NavStateLink
|
<NavStateLink
|
||||||
|
|
@ -114,11 +141,28 @@ export default function TrainingModulesListPage() {
|
||||||
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
|
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p style={{ margin: '0.35rem 0 0', fontSize: '0.8rem', color: 'var(--text3)' }}>
|
<div style={{ marginTop: '0.65rem' }}>
|
||||||
Sichtbarkeit: <strong>{r.visibility || '—'}</strong>
|
<SkillProfileCompact
|
||||||
</p>
|
summary={skillSummaries[`training_module:${r.id}`]}
|
||||||
|
loading={summariesLoading}
|
||||||
|
displayLimit={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
<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
|
<NavStateLink
|
||||||
to={`/planning/training-modules/${r.id}`}
|
to={`/planning/training-modules/${r.id}`}
|
||||||
returnContext={modulesListReturn}
|
returnContext={modulesListReturn}
|
||||||
|
|
@ -136,6 +180,14 @@ export default function TrainingModulesListPage() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SkillProfileFullModal
|
||||||
|
open={Boolean(profileModal)}
|
||||||
|
onClose={() => setProfileModal(null)}
|
||||||
|
artifactType={profileModal?.artifactType}
|
||||||
|
artifactId={profileModal?.artifactId}
|
||||||
|
title={profileModal?.title}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
import { formatDurationDisplay, formatSessionDurationRange } from './trainingDurationUtils'
|
import { formatDurationDisplay, formatSessionDurationRange } from './trainingDurationUtils'
|
||||||
|
import {
|
||||||
|
frameworkSkillSummaryKey,
|
||||||
|
maxSelectedSkillClubPercent,
|
||||||
|
skillEntryFromSummary,
|
||||||
|
} from './skillProfileListHelpers'
|
||||||
|
|
||||||
export function frameworkSessionDurationLabel(row) {
|
export function frameworkSessionDurationLabel(row) {
|
||||||
return formatSessionDurationRange(row?.session_duration_min, row?.session_duration_max, {
|
return formatSessionDurationRange(row?.session_duration_min, row?.session_duration_max, {
|
||||||
|
|
@ -109,6 +114,10 @@ export const EMPTY_FRAMEWORK_IMPORT_FILTERS = {
|
||||||
durationRangeFrom: '',
|
durationRangeFrom: '',
|
||||||
durationRangeTo: '',
|
durationRangeTo: '',
|
||||||
durationPresetMin: null,
|
durationPresetMin: null,
|
||||||
|
skillIds: [],
|
||||||
|
skillSort: 'title',
|
||||||
|
skillMinClubPercent: 0,
|
||||||
|
skillDisplayLimit: 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasActiveFrameworkImportFilters(filters = {}) {
|
export function hasActiveFrameworkImportFilters(filters = {}) {
|
||||||
|
|
@ -122,6 +131,9 @@ export function hasActiveFrameworkImportFilters(filters = {}) {
|
||||||
if (String(f.durationRangeTo || '').trim() !== '') return true
|
if (String(f.durationRangeTo || '').trim() !== '') return true
|
||||||
}
|
}
|
||||||
if (f.durationMode === 'preset' && f.durationPresetMin != null) 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
|
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
|
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 Vereins-Maximum`)
|
||||||
|
}
|
||||||
|
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) {
|
||||||
|
parts.push('Sortierung: Fähigkeiten-Stärke')
|
||||||
|
}
|
||||||
|
|
||||||
if ((f.focusAreaIds || []).length) {
|
if ((f.focusAreaIds || []).length) {
|
||||||
const names = f.focusAreaIds.map((id) => nameById(catalogs.focusAreas, id))
|
const names = f.focusAreaIds.map((id) => nameById(catalogs.focusAreas, id))
|
||||||
parts.push(`Fokus: ${names.join(', ')}`)
|
parts.push(`Fokus: ${names.join(', ')}`)
|
||||||
|
|
@ -167,14 +190,16 @@ export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) {
|
||||||
/**
|
/**
|
||||||
* Client-Filter für Rahmenprogramm-Auswahl (Liste / Import-Dialog).
|
* 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 f = { ...EMPTY_FRAMEWORK_IMPORT_FILTERS, ...filters }
|
||||||
const q = (f.query || '').trim().toLowerCase()
|
const q = (f.query || '').trim().toLowerCase()
|
||||||
const focusIds = new Set((f.focusAreaIds || []).map(String))
|
const focusIds = new Set((f.focusAreaIds || []).map(String))
|
||||||
const typeIds = new Set((f.trainingTypeIds || []).map(String))
|
const typeIds = new Set((f.trainingTypeIds || []).map(String))
|
||||||
const tgIds = new Set((f.targetGroupIds || []).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) {
|
if (q) {
|
||||||
const blob = [
|
const blob = [
|
||||||
r.title,
|
r.title,
|
||||||
|
|
@ -212,6 +237,32 @@ export function filterFrameworkPrograms(rows, filters = {}) {
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (skillIds.length && skillSummaries) {
|
||||||
|
list = list.filter((r) => {
|
||||||
|
const summary = skillSummaries[frameworkSkillSummaryKey(r.id)]
|
||||||
|
if (!summary) return minClubPct === 0
|
||||||
|
return skillIds.some((sid) => {
|
||||||
|
const sk = skillEntryFromSummary(summary, sid)
|
||||||
|
if (!sk) return false
|
||||||
|
const pct = sk.universal_percent
|
||||||
|
if (pct == null) return minClubPct === 0
|
||||||
|
return pct >= 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) {
|
export function frameworkProgramOptionLabel(row) {
|
||||||
|
|
|
||||||
62
frontend/src/utils/skillProfileListHelpers.js
Normal file
62
frontend/src/utils/skillProfileListHelpers.js
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
export function frameworkSkillSummaryKey(id) {
|
||||||
|
return `framework_program:${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleSkillSummaryKey(id) {
|
||||||
|
return `training_module:${id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skillEntryFromSummary(summary, skillId) {
|
||||||
|
if (!summary?.skills) return null
|
||||||
|
return summary.skills.find((s) => String(s.skill_id) === String(skillId)) || null
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = Number(value)
|
||||||
|
return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeilen für Kompakt-Anzeige: gewählte Fähigkeiten oder Top je Kategorie. */
|
||||||
|
export function compactSkillDisplayRows(summary, { skillIds = [], limit = 6 } = {}) {
|
||||||
|
if (!summary) return []
|
||||||
|
if (skillIds.length) {
|
||||||
|
return skillIds
|
||||||
|
.map((id) => skillEntryFromSummary(summary, id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => (b.universal_percent ?? 0) - (a.universal_percent ?? 0))
|
||||||
|
.slice(0, limit)
|
||||||
|
}
|
||||||
|
return (summary.top_by_category || []).slice(0, limit)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user