Enhance Skill Scoring and Profile Features
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m15s
- Updated the skill scoring specification to include club-specific metrics and improved aggregation methods for skill profiles. - Introduced new API endpoints for batch skill profile summaries, allowing for efficient retrieval of compact skill data. - Enhanced frontend components to display skill profiles with club comparisons, improving user interaction and visibility of skill strengths. - Added filtering options for skills in the framework programs, enabling users to refine selections based on training weight relative to club maximums. - Improved CSS styles for skill profile displays, ensuring a cohesive and user-friendly interface across the application.
This commit is contained in:
parent
5200895a73
commit
78c6c51520
|
|
@ -58,7 +58,9 @@ 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 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -17,10 +17,13 @@ from skill_scoring import (
|
|||
collect_module_exercise_occurrences,
|
||||
collect_progression_graph_exercise_occurrences,
|
||||
collect_unit_exercise_occurrences,
|
||||
compute_club_corpus_reference,
|
||||
compute_corpus_skill_max_weights,
|
||||
compute_skill_profile,
|
||||
match_score_for_skill_ids,
|
||||
profile_for_occurrences,
|
||||
reference_scale_meta,
|
||||
top_categories_summary,
|
||||
)
|
||||
|
||||
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()]
|
||||
|
||||
ref_max = compute_corpus_skill_max_weights(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
corpus = _load_club_corpus(cur, tenant)
|
||||
ref_max = corpus["max_by_skill"]
|
||||
ref_by_skill = corpus["ref_by_skill"]
|
||||
|
||||
all_occurrences: List[ExerciseOccurrence] = []
|
||||
slot_profiles: List[Dict[str, Any]] = []
|
||||
|
|
@ -118,14 +118,22 @@ def framework_program_skill_profile(
|
|||
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": {
|
||||
"skills_in_corpus": len(ref_max),
|
||||
"description": "universal_percent = Anteil am höchsten Trainingsgewicht dieser Fähigkeit in der sichtbaren Bibliothek",
|
||||
"reference_scale": reference_scale_meta(corpus),
|
||||
"club_best_by_skill": {
|
||||
str(k): v for k, v in ref_by_skill.items()
|
||||
},
|
||||
"overall": overall,
|
||||
"slots": slot_profiles,
|
||||
|
|
@ -142,24 +150,23 @@ def training_module_skill_profile(
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row = _module_access(cur, module_id, profile_id, role)
|
||||
ref_max = compute_corpus_skill_max_weights(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
corpus = _load_club_corpus(cur, tenant)
|
||||
ref_max = corpus["max_by_skill"]
|
||||
ref_by_skill = corpus["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": {
|
||||
"skills_in_corpus": len(ref_max),
|
||||
"reference_scale": reference_scale_meta(corpus),
|
||||
"club_best_by_skill": {
|
||||
str(k): v for k, v in ref_by_skill.items()
|
||||
},
|
||||
"overall": overall,
|
||||
}
|
||||
|
|
@ -175,12 +182,9 @@ def progression_graph_skill_profile(
|
|||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row = _require_graph_read(cur, graph_id, profile_id, role)
|
||||
ref_max = compute_corpus_skill_max_weights(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
corpus = _load_club_corpus(cur, tenant)
|
||||
ref_max = corpus["max_by_skill"]
|
||||
ref_by_skill = corpus["ref_by_skill"]
|
||||
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
|
||||
overall = (
|
||||
profile_for_occurrences(
|
||||
|
|
@ -192,17 +196,96 @@ def progression_graph_skill_profile(
|
|||
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": {
|
||||
"skills_in_corpus": len(ref_max),
|
||||
"reference_scale": reference_scale_meta(corpus),
|
||||
"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)
|
||||
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")
|
||||
def skill_discovery_suggestions(
|
||||
skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
|
||||
|
|
@ -278,7 +361,7 @@ def skill_discovery_suggestions(
|
|||
"match": match,
|
||||
"skill_profile_summary": {
|
||||
"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,
|
||||
"skill_profile_summary": {
|
||||
"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,
|
||||
"skill_profile_summary": {
|
||||
"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
|
||||
|
||||
|
||||
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]:
|
||||
return compute_skill_profile([], {})
|
||||
|
|
|
|||
|
|
@ -471,6 +471,337 @@ def merge_skill_weights_into_max(
|
|||
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"),
|
||||
}
|
||||
)
|
||||
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,
|
||||
*,
|
||||
profile_id: int,
|
||||
effective_club_id: Optional[int],
|
||||
include_artifact_summaries: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Stärkstes Trainingsgewicht je Fähigkeit über alle Vereins-Artefakte (sichtbar im aktiven Verein).
|
||||
Optional: kompakte Profile aller gescannten Artefakte (ein Durchlauf für Listen).
|
||||
"""
|
||||
from tenant_context import club_library_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]] = {}
|
||||
|
||||
if effective_club_id is None:
|
||||
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",
|
||||
profile_id=profile_id,
|
||||
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,
|
||||
)
|
||||
fw_rows = cur.fetchall()
|
||||
if fw_rows:
|
||||
fw_ids = [int(r["id"]) for r in fw_rows]
|
||||
titles = {int(r["id"]): r.get("title") for r in fw_rows}
|
||||
occ_map = batch_framework_occurrences_by_id(cur, fw_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)
|
||||
profiles = batch_compute_profiles(occ_map, skills_map)
|
||||
for fid, prof in profiles.items():
|
||||
ingest("framework_program", fid, titles.get(fid), prof)
|
||||
|
||||
vis_clause, vis_params = club_library_visibility_sql(
|
||||
alias="m",
|
||||
profile_id=profile_id,
|
||||
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,
|
||||
)
|
||||
mod_rows = cur.fetchall()
|
||||
if mod_rows:
|
||||
mod_ids = [int(r["id"]) for r in mod_rows]
|
||||
titles = {int(r["id"]): r.get("title") for r in mod_rows}
|
||||
occ_map = batch_module_occurrences_by_id(cur, mod_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)
|
||||
profiles = batch_compute_profiles(occ_map, skills_map)
|
||||
for mid, prof in profiles.items():
|
||||
ingest("training_module", mid, titles.get(mid), prof)
|
||||
|
||||
vis_clause, vis_params = club_library_visibility_sql(
|
||||
alias="g",
|
||||
profile_id=profile_id,
|
||||
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("progression_graph", 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 {
|
||||
"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,
|
||||
*,
|
||||
|
|
@ -480,102 +811,16 @@ def compute_corpus_skill_max_weights(
|
|||
limit_per_type: int = 50,
|
||||
) -> Dict[int, float]:
|
||||
"""
|
||||
Maximum absolutes Trainingsgewicht je Fähigkeit über sichtbare Bibliotheksartefakte.
|
||||
Basis für universal_percent (Skala über alle Programme).
|
||||
Vereins-Referenz je Fähigkeit (Legacy-Hülle — nutzt compute_club_corpus_reference).
|
||||
role/limit_per_type werden ignoriert (Vereinskontext, alle Vereins-Artefakte).
|
||||
"""
|
||||
from tenant_context import library_content_visibility_sql
|
||||
|
||||
max_by_skill: Dict[int, float] = {}
|
||||
|
||||
def scan_frameworks():
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="fp",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=effective_club_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT fp.id FROM training_framework_programs fp
|
||||
WHERE ({vis_clause})
|
||||
ORDER BY fp.updated_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(*vis_params, limit_per_type),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
fid = int(row["id"])
|
||||
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)
|
||||
merge_skill_weights_into_max(max_by_skill, prof)
|
||||
|
||||
def scan_modules():
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="m",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=effective_club_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT m.id FROM training_modules m
|
||||
WHERE ({vis_clause})
|
||||
ORDER BY m.updated_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(*vis_params, limit_per_type),
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
mid = int(row["id"])
|
||||
occ = collect_module_exercise_occurrences(cur, mid)
|
||||
if not occ:
|
||||
continue
|
||||
prof = profile_for_occurrences(cur, occ)
|
||||
merge_skill_weights_into_max(max_by_skill, prof)
|
||||
|
||||
def scan_graphs():
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="g",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=effective_club_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT g.id FROM exercise_progression_graphs g
|
||||
WHERE ({vis_clause})
|
||||
ORDER BY g.updated_at DESC NULLS LAST
|
||||
LIMIT %s
|
||||
""",
|
||||
(*vis_params, limit_per_type),
|
||||
)
|
||||
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
|
||||
)
|
||||
merge_skill_weights_into_max(max_by_skill, prof)
|
||||
|
||||
scan_frameworks()
|
||||
scan_modules()
|
||||
scan_graphs()
|
||||
return max_by_skill
|
||||
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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -24,3 +24,17 @@ export async function getSkillDiscoverySuggestions(skillIds, opts = {}) {
|
|||
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 || [],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2690,6 +2690,107 @@ html.modal-scroll-locked .app-main {
|
|||
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 {
|
||||
padding: 1.15rem 1.25rem;
|
||||
max-width: 52rem;
|
||||
|
|
|
|||
|
|
@ -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,27 @@ 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)}
|
||||
>
|
||||
Vollständiges Profil
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<SkillProfileCompact
|
||||
summary={skillSummary}
|
||||
skillIds={skillFilterIds}
|
||||
loading={skillSummaryLoading}
|
||||
displayLimit={skillDisplayLimit}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<footer className="fw-prog-card__actions">
|
||||
<NavStateLink
|
||||
to={`/planning/framework-programs/${row.id}`}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export default function FrameworkProgramsFilterBlock({
|
|||
catalogFocusAreas = [],
|
||||
catalogTrainingTypes = [],
|
||||
catalogTargetGroups = [],
|
||||
catalogSkills = [],
|
||||
skillSummaries = null,
|
||||
disabled = false,
|
||||
durationRadioName = 'fw-duration-mode',
|
||||
showHint = true,
|
||||
|
|
@ -31,8 +33,8 @@ export default function FrameworkProgramsFilterBlock({
|
|||
)
|
||||
|
||||
const matchCount = useMemo(
|
||||
() => filterFrameworkPrograms(programs, filters).length,
|
||||
[programs, filters]
|
||||
() => filterFrameworkPrograms(programs, filters, skillSummaries).length,
|
||||
[programs, filters, skillSummaries]
|
||||
)
|
||||
|
||||
const totalCount = (programs || []).length
|
||||
|
|
@ -43,8 +45,9 @@ export default function FrameworkProgramsFilterBlock({
|
|||
focusAreas: catalogFocusAreas,
|
||||
trainingTypes: catalogTrainingTypes,
|
||||
targetGroups: catalogTargetGroups,
|
||||
skills: catalogSkills,
|
||||
}),
|
||||
[filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups]
|
||||
[filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups, catalogSkills]
|
||||
)
|
||||
|
||||
const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
|
||||
|
|
@ -285,6 +288,61 @@ export default function FrameworkProgramsFilterBlock({
|
|||
</div>
|
||||
</div>
|
||||
) : 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>
|
||||
{showHint ? (
|
||||
<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) {
|
||||
if (hasReferenceScale && skill?.universal_percent != null) {
|
||||
return `${skill.universal_percent}% Bibliothek`
|
||||
return `${skill.universal_percent}% vom Vereins-Maximum`
|
||||
}
|
||||
return formatWeight(skillWeight(skill))
|
||||
}
|
||||
|
|
@ -43,7 +43,14 @@ function CategoryTopSkill({ skill, maxWeight, hasReferenceScale }) {
|
|||
</div>
|
||||
{hasReferenceScale ? (
|
||||
<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>
|
||||
) : null}
|
||||
</li>
|
||||
|
|
@ -111,7 +118,7 @@ export default function SkillProfilePanel({
|
|||
loading = false,
|
||||
error = '',
|
||||
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,
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
|
|
|
|||
|
|
@ -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,16 @@ 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 +40,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.listSkills({ 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 +68,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 {
|
||||
|
|
@ -136,6 +167,8 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
catalogFocusAreas={catalogFocusAreas}
|
||||
catalogTrainingTypes={catalogTrainingTypes}
|
||||
catalogTargetGroups={catalogTargetGroups}
|
||||
catalogSkills={catalogSkills}
|
||||
skillSummaries={skillSummaries}
|
||||
durationRadioName="fw-list-duration-mode"
|
||||
className="fw-prog-filter-block--list"
|
||||
/>
|
||||
|
|
@ -157,6 +190,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 +208,14 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SkillProfileFullModal
|
||||
open={Boolean(profileModal)}
|
||||
onClose={() => setProfileModal(null)}
|
||||
artifactType={profileModal?.artifactType}
|
||||
artifactId={profileModal?.artifactId}
|
||||
title={profileModal?.title}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import api from '../utils/api'
|
||||
import NavStateLink from '../components/NavStateLink'
|
||||
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'
|
||||
|
|
@ -12,6 +14,9 @@ export default function TrainingModulesListPage() {
|
|||
const [rows, setRows] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [skillSummaries, setSkillSummaries] = useState({})
|
||||
const [summariesLoading, setSummariesLoading] = useState(false)
|
||||
const [profileModal, setProfileModal] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -31,6 +36,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 +86,7 @@ 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. Fähigkeiten werden im Vereinskontext verglichen.
|
||||
</p>
|
||||
</div>
|
||||
<NavStateLink
|
||||
|
|
@ -114,11 +141,28 @@ export default function TrainingModulesListPage() {
|
|||
({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.65rem' }}>
|
||||
<SkillProfileCompact
|
||||
summary={skillSummaries[`training_module:${r.id}`]}
|
||||
loading={summariesLoading}
|
||||
displayLimit={4}
|
||||
/>
|
||||
</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}
|
||||
|
|
@ -136,6 +180,14 @@ export default function TrainingModulesListPage() {
|
|||
))}
|
||||
</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 {
|
||||
frameworkSkillSummaryKey,
|
||||
maxSelectedSkillClubPercent,
|
||||
skillEntryFromSummary,
|
||||
} 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: 10,
|
||||
}
|
||||
|
||||
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 Vereins-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,32 @@ export function filterFrameworkPrograms(rows, filters = {}) {
|
|||
|
||||
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) {
|
||||
|
|
|
|||
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