KPI-Scroing, Filter, etc, #43
96
.claude/docs/technical/SKILL_SCORING_SPEC.md
Normal file
96
.claude/docs/technical/SKILL_SCORING_SPEC.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# Gewichtetes Fähigkeiten-Scoring (Phase 3)
|
||||
|
||||
**Stand:** 2026-05-20
|
||||
**Status:** Variante A (regelbasiert) umgesetzt — **v1.2** (Kategorien-Gruppierung + universelle Skala)
|
||||
**Modul:** `backend/skill_scoring.py`, Router `skill_profiles`
|
||||
|
||||
## Ziel
|
||||
|
||||
Trainer wählen **Schwerpunkt-Fähigkeiten** und erhalten Vorschläge für **Rahmenprogramme**, **Trainingsmodule** und **Regressionspfade** (Progressionsgraphen), deren Übungen diese Fähigkeiten stark abdecken.
|
||||
|
||||
## Datenquellen
|
||||
|
||||
| Artefakt | Übungen aus |
|
||||
|----------|-------------|
|
||||
| Rahmenprogramm (gesamt) | Alle Blueprint-`training_units` der Slots → `training_unit_section_items` |
|
||||
| Rahmenprogramm (pro Slot) | Blueprint einer Session |
|
||||
| Trainingsmodul | `training_module_items` (nur `item_type = exercise`) |
|
||||
| Progressionsgraph | `from_exercise_id` + `to_exercise_id` je Kante (Vorkommen zählt) |
|
||||
|
||||
Fähigkeiten je Übung: `exercise_skills` → `skills` (nur `status = active`).
|
||||
|
||||
## Gewichtungsformel (v1.1)
|
||||
|
||||
Pro **Übungsvorkommen** (eine Zeile im Ablauf / Modul / Kanten-Endpunkt):
|
||||
|
||||
1. **Basis-Minuten** = `planned_duration_min` der Position, sonst Default (Einheit/Modul: 8 Min, Graph: 10 Min).
|
||||
2. Pro verknüpfte Fähigkeit der Übung:
|
||||
- `Beitrag = Basis-Minuten × Anzahl Vorkommen × Link-Faktor`
|
||||
- **Link-Faktor** = Intensität × Stufen-Faktor
|
||||
|
||||
### Intensität (Nutzeneinschätzung, UI-Feld)
|
||||
|
||||
| Wert | Faktor |
|
||||
|------|--------|
|
||||
| niedrig | 0,85 |
|
||||
| mittel / leer | 1,0 |
|
||||
| hoch | 1,2 |
|
||||
|
||||
### Stufen-Spanne (`required_level` → `target_level`, UI „von/bis“)
|
||||
|
||||
Kanonische Slugs: basis … optimierung (1–5). Fehlen beide: Faktor 1,0.
|
||||
|
||||
- **Spanne** = Anzahl Stufen von „von“ bis „bis“ (1–5)
|
||||
- **Mittelpunkt** = durchschnittliche Stufe
|
||||
- Faktor ≈ `(0,92 + 0,04 × Spanne) × (0,95 + 0,025 × Mittelpunkt)` → typisch 0,96–1,20
|
||||
|
||||
Beispiel: von Grundlagen bis Aufbau (Spanne 2) > nur Basis (Spanne 1).
|
||||
|
||||
### Bewusst nicht im Scoring
|
||||
|
||||
| Feld | Grund |
|
||||
|------|--------|
|
||||
| `is_primary` | Perspektivabhängig; bleibt in Übungs-UI, fließt nicht ins Profil ein |
|
||||
| `development_contribution` | Legacy-DB-Feld, in UI nicht gepflegt |
|
||||
|
||||
Aggregation:
|
||||
|
||||
- Summe pro `skill_id` → `weight` / `score` (Trainingsgewicht in gewichteten Minuten — **absolut, über Programme vergleichbar**)
|
||||
- `artifact_share_percent` / `share_percent` = Anteil an `total_weight` **innerhalb dieses Artefakts** (summiert 100 % — nur noch sekundär)
|
||||
- `by_main_category[]` → je Unterkategorie `top_skill` (stärkste Fähigkeit nach absolutem Gewicht)
|
||||
- `universal_percent` = `weight / max_weight_in_corpus(skill_id) × 100` — Referenz aus **Vereins-Artefakten** (`visibility=club`, aktiver Verein): Rahmenprogramme, Module, Regressionspfade
|
||||
- `club_best` / `club_best_by_skill`: stärkstes Vereins-Element je Fähigkeit (Titel, Typ, Gewicht)
|
||||
- Listen: `POST /api/skill-profiles/batch-summaries` — ein Corpus-Durchlauf, kompakte Profile für viele IDs
|
||||
|
||||
Discovery-Sortierung und Vorschläge nutzen **`match_score`** (= Summe absoluter Gewichte der gewählten Fähigkeiten), nicht den Plan-internen Anteil.
|
||||
|
||||
## API
|
||||
|
||||
| Methode | Pfad | Beschreibung |
|
||||
|---------|------|--------------|
|
||||
| GET | `/api/training-framework-programs/{id}/skill-profile` | `overall` + `slots[]` mit je `profile` |
|
||||
| GET | `/api/training-modules/{id}/skill-profile` | `overall` |
|
||||
| GET | `/api/exercise-progression-graphs/{id}/skill-profile` | `overall` |
|
||||
| GET | `/api/skill-discovery/suggestions?skill_ids=1,2,3` | Ranking sichtbarer Artefakte; Query `types`, `limit` |
|
||||
|
||||
Zugriff: `get_tenant_context` + gleiche Sichtbarkeit wie Parent-Artefakt (`library_content_visibility_sql`).
|
||||
|
||||
## UI
|
||||
|
||||
- **Rahmenprogramm bearbeiten:** Panel „Fähigkeiten-Schwerpunkte“, inkl. Aufklapp pro Session
|
||||
- **Trainingsmodul bearbeiten:** Panel „Fähigkeiten im Modul“
|
||||
- **Progressionsgraph:** Panel „Fähigkeiten entlang des Pfads“
|
||||
- **Fähigkeiten-Seite → Planungs-Vorschläge:** Multi-Select + Bibliothekssuche
|
||||
|
||||
Profil wird nach **Speichern** neu geladen (`skillProfileTick`).
|
||||
|
||||
## Grenzen / später
|
||||
|
||||
- Kein Cache in DB (`skill_profile_json`) — on-the-fly; bei Performance >50 Artefakte serverseitiger Index
|
||||
- Entwicklungsziele am Rahmenkopf bleiben Freitext (kein Scoring)
|
||||
- KI-Zusammenfassung (Variante B Roadmap) nicht Teil von v1.0
|
||||
- Trainings**einheiten** (Kalender) optional als nächste Erweiterung
|
||||
|
||||
## Tests
|
||||
|
||||
- `backend/tests/test_skill_scoring.py` — Multiplikator, Aggregation, Match-Score
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# Rahmenprogramm: Filter, Dauer, Fähigkeiten-Schwerpunkte (Roadmap)
|
||||
|
||||
**Stand:** 2026-05-20
|
||||
**Status:** Phase 1 umgesetzt (Listen + Import-Filter); Phase 2–3 offen
|
||||
**Status:** Phase 1 umgesetzt; Phase 3 v1.0 umgesetzt (regelbasiert); Phase 2 teilweise offen
|
||||
|
||||
## Phase 1 (umgesetzt)
|
||||
|
||||
|
|
@ -29,23 +29,16 @@
|
|||
|
||||
**API-Erweiterung (optional):** `GET /api/training-framework-programs?focus_area_id=&training_type_id=&duration_min=` serverseitig — sinnvoll ab >50 Rahmen in der Bibliothek.
|
||||
|
||||
## Phase 3 — Fähigkeiten aus Übungen (Schwerpunkte dynamisch)
|
||||
## Phase 3 — Fähigkeiten aus Übungen (umgesetzt v1.0)
|
||||
|
||||
### Ziel
|
||||
**Spec:** `.claude/docs/technical/SKILL_SCORING_SPEC.md`
|
||||
|
||||
Aus allen Übungen in allen Slots eines Rahmenprogramms die verknüpften **Fähigkeiten** (`exercise_skills` → `skills`, ggf. Fokusbereich der Fähigkeit) aggregieren, gewichten und als **Vorschlags-Schwerpunkte** oder Metadaten am Rahmen anzeigen (nicht zwingend automatisch in den Kopf schreiben).
|
||||
- Gewichtetes Profil: Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph
|
||||
- `GET /api/skill-discovery/suggestions?skill_ids=…` für Bibliotheks-Vorschläge
|
||||
- UI: Profil-Panels in Editoren + Tab „Planungs-Vorschläge“ auf der Fähigkeiten-Seite
|
||||
- **Kein** automatisches Überschreiben der Stammdaten-Fokusbereiche
|
||||
|
||||
### Variante A — Regelbasiert (ohne KI)
|
||||
|
||||
1. Pro Blueprint-Unit alle `exercise_id` aus `training_unit_section_items` sammeln.
|
||||
2. Join `exercise_skills` (optional Gewicht: `planned_duration_min` der Zeile, Anzahl Vorkommen, Primär-Fähigkeit).
|
||||
3. Top-N Fähigkeiten / Fokusbereiche nach Summe oder Anteil an Gesamtminuten.
|
||||
4. Ergebnis cachen in `training_framework_programs.skill_profile_json` (Migration) oder nur on-the-fly bei GET Detail.
|
||||
|
||||
**Vorteil:** reproduzierbar, offline, Governance-konform.
|
||||
**Aufwand:** ca. 1–2 Tage Backend + kleine UI-Karte „Fähigkeiten-Profil (aus Übungen)“.
|
||||
|
||||
### Variante B — KI-Zusammenfassung (OpenRouter, optional)
|
||||
### Variante B — KI-Zusammenfassung (OpenRouter, optional, offen)
|
||||
|
||||
1. Input: Titel Rahmen, Ziele (Text), Liste Übungstitel + Dauer + vorhandene Skill-Namen.
|
||||
2. Prompt: strukturiertes JSON (`suggested_focus_areas[]`, `skill_emphasis[]`, `rationale_de`).
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -208,6 +208,7 @@ app.include_router(media_assets.router)
|
|||
app.include_router(media_assets.admin_rights_router)
|
||||
app.include_router(media_assets.admin_legal_hold_router)
|
||||
app.include_router(skills.router)
|
||||
app.include_router(skill_profiles.router)
|
||||
app.include_router(training_planning.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(training_modules.router)
|
||||
|
|
|
|||
689
backend/routers/skill_profiles.py
Normal file
689
backend/routers/skill_profiles.py
Normal file
|
|
@ -0,0 +1,689 @@
|
|||
"""
|
||||
Fähigkeiten-Profile und Vorschläge (Phase 3) für Planungsartefakte.
|
||||
|
||||
GET …/skill-profile — gewichtetes Profil aus verknüpften Übungen.
|
||||
GET /api/skill-discovery/suggestions — Rahmenprogramme, Module, Progressionsgraphen nach Fähigkeiten.
|
||||
"""
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from db import get_db, get_cursor, r2d
|
||||
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
||||
|
||||
from skill_scoring import (
|
||||
GRAPH_DEFAULT_ITEM_MINUTES,
|
||||
ExerciseOccurrence,
|
||||
batch_compute_profiles,
|
||||
batch_framework_occurrences_by_id,
|
||||
batch_module_occurrences_by_id,
|
||||
collect_module_exercise_occurrences,
|
||||
collect_progression_graph_exercise_occurrences,
|
||||
collect_unit_exercise_occurrences,
|
||||
compact_profile_summary,
|
||||
compute_planning_corpus_by_type,
|
||||
compute_club_corpus_reference,
|
||||
compute_corpus_skill_max_weights,
|
||||
compute_skill_profile,
|
||||
corpus_for_artifact_type,
|
||||
fetch_exercise_skills_bulk,
|
||||
match_score_for_skill_ids,
|
||||
profile_for_occurrences,
|
||||
reference_scale_meta,
|
||||
top_categories_summary,
|
||||
)
|
||||
|
||||
from routers.training_framework_programs import _framework_access
|
||||
from routers.training_modules import _module_access
|
||||
from routers.exercise_progression_graphs import _require_graph_read
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["skill_profiles"])
|
||||
|
||||
|
||||
def _parse_skill_ids_param(raw: Optional[str]) -> List[int]:
|
||||
if not raw or not str(raw).strip():
|
||||
return []
|
||||
out: List[int] = []
|
||||
for part in str(raw).split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
n = int(part)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="skill_ids: ungültige ID") from None
|
||||
if n > 0 and n not in out:
|
||||
out.append(n)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/training-framework-programs/{framework_id}/skill-profile")
|
||||
def framework_program_skill_profile(
|
||||
framework_id: int,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row = _framework_access(cur, framework_id, profile_id, role)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT s.id, s.sort_order, s.title,
|
||||
tu.id AS blueprint_unit_id
|
||||
FROM training_framework_slots s
|
||||
LEFT JOIN training_units tu ON tu.framework_slot_id = s.id
|
||||
WHERE s.framework_program_id = %s
|
||||
ORDER BY s.sort_order
|
||||
""",
|
||||
(framework_id,),
|
||||
)
|
||||
slots_raw = [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
bundle = _load_planning_corpus(cur, tenant)
|
||||
tc = corpus_for_artifact_type(bundle, "framework_program")
|
||||
ref_max = tc["max_by_skill"]
|
||||
ref_by_skill = tc["ref_by_skill"]
|
||||
|
||||
all_occurrences: List[ExerciseOccurrence] = []
|
||||
slot_profiles: List[Dict[str, Any]] = []
|
||||
|
||||
for slot in slots_raw:
|
||||
uid = slot.get("blueprint_unit_id")
|
||||
slot_occ: List[ExerciseOccurrence] = []
|
||||
slot_label = (slot.get("title") or "").strip() or f"Session {(slot.get('sort_order') or 0) + 1}"
|
||||
if uid:
|
||||
raw_occ = collect_unit_exercise_occurrences(cur, int(uid))
|
||||
slot_occ = [
|
||||
ExerciseOccurrence(
|
||||
exercise_id=o.exercise_id,
|
||||
planned_duration_min=o.planned_duration_min,
|
||||
context_label=slot_label,
|
||||
)
|
||||
for o in raw_occ
|
||||
]
|
||||
all_occurrences.extend(slot_occ)
|
||||
else:
|
||||
slot_occ = []
|
||||
slot_profile = (
|
||||
profile_for_occurrences(cur, slot_occ, reference_max_by_skill=ref_max)
|
||||
if slot_occ
|
||||
else _empty_profile()
|
||||
)
|
||||
slot_profiles.append(
|
||||
{
|
||||
"slot_id": slot["id"],
|
||||
"slot_title": slot.get("title"),
|
||||
"sort_order": slot.get("sort_order"),
|
||||
"blueprint_training_unit_id": uid,
|
||||
"exercise_occurrence_count": len(slot_occ),
|
||||
"profile": slot_profile,
|
||||
}
|
||||
)
|
||||
|
||||
overall = (
|
||||
profile_for_occurrences(cur, all_occurrences, reference_max_by_skill=ref_max)
|
||||
if all_occurrences
|
||||
else _empty_profile()
|
||||
)
|
||||
_enrich_profile_club_best(overall, ref_by_skill, "framework_program", framework_id)
|
||||
for slot in slot_profiles:
|
||||
_enrich_profile_club_best(
|
||||
slot.get("profile") or {},
|
||||
ref_by_skill,
|
||||
"framework_program",
|
||||
framework_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"artifact_type": "framework_program",
|
||||
"artifact_id": framework_id,
|
||||
"artifact_title": row.get("title"),
|
||||
"reference_scale": reference_scale_meta(
|
||||
tc, "framework_program", effective_club_id=tenant.effective_club_id
|
||||
),
|
||||
"club_best_by_skill": {
|
||||
str(k): v for k, v in ref_by_skill.items()
|
||||
},
|
||||
"overall": overall,
|
||||
"slots": slot_profiles,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/training-modules/{module_id}/skill-profile")
|
||||
def training_module_skill_profile(
|
||||
module_id: int,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row = _module_access(cur, module_id, profile_id, role)
|
||||
bundle = _load_planning_corpus(cur, tenant)
|
||||
tc = corpus_for_artifact_type(bundle, "training_module")
|
||||
ref_max = tc["max_by_skill"]
|
||||
ref_by_skill = tc["ref_by_skill"]
|
||||
occurrences = collect_module_exercise_occurrences(cur, module_id)
|
||||
overall = (
|
||||
profile_for_occurrences(cur, occurrences, reference_max_by_skill=ref_max)
|
||||
if occurrences
|
||||
else _empty_profile()
|
||||
)
|
||||
_enrich_profile_club_best(overall, ref_by_skill, "training_module", module_id)
|
||||
return {
|
||||
"artifact_type": "training_module",
|
||||
"artifact_id": module_id,
|
||||
"artifact_title": row.get("title"),
|
||||
"reference_scale": reference_scale_meta(
|
||||
tc, "training_module", effective_club_id=tenant.effective_club_id
|
||||
),
|
||||
"club_best_by_skill": {
|
||||
str(k): v for k, v in ref_by_skill.items()
|
||||
},
|
||||
"overall": overall,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/exercise-progression-graphs/{graph_id}/skill-profile")
|
||||
def progression_graph_skill_profile(
|
||||
graph_id: int,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
row = _require_graph_read(cur, graph_id, profile_id, role)
|
||||
bundle = _load_planning_corpus(cur, tenant)
|
||||
tc = corpus_for_artifact_type(bundle, "progression_graph")
|
||||
ref_max = tc["max_by_skill"]
|
||||
ref_by_skill = tc["ref_by_skill"]
|
||||
occurrences = collect_progression_graph_exercise_occurrences(cur, graph_id)
|
||||
overall = (
|
||||
profile_for_occurrences(
|
||||
cur,
|
||||
occurrences,
|
||||
default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES,
|
||||
reference_max_by_skill=ref_max,
|
||||
)
|
||||
if occurrences
|
||||
else _empty_profile()
|
||||
)
|
||||
_enrich_profile_club_best(overall, ref_by_skill, "progression_graph", graph_id)
|
||||
return {
|
||||
"artifact_type": "progression_graph",
|
||||
"artifact_id": graph_id,
|
||||
"artifact_title": row.get("name"),
|
||||
"reference_scale": reference_scale_meta(
|
||||
tc, "progression_graph", effective_club_id=tenant.effective_club_id
|
||||
),
|
||||
"club_best_by_skill": {
|
||||
str(k): v for k, v in ref_by_skill.items()
|
||||
},
|
||||
"overall": overall,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/skill-profiles/batch-summaries")
|
||||
def batch_skill_profile_summaries(
|
||||
data: dict,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""
|
||||
Kompakte Fähigkeiten-Profile für Listen (ein Corpus-Scan, Batch-SQL).
|
||||
Body: { framework_program_ids?: number[], training_module_ids?: number[] }
|
||||
"""
|
||||
fp_ids = _parse_id_list(data.get("framework_program_ids"))
|
||||
mod_ids = _parse_id_list(data.get("training_module_ids"))
|
||||
if not fp_ids and not mod_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="framework_program_ids oder training_module_ids erforderlich",
|
||||
)
|
||||
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
summaries: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
bundle = compute_planning_corpus_by_type(
|
||||
cur,
|
||||
profile_id=tenant.profile_id,
|
||||
role=role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
include_artifact_summaries=True,
|
||||
)
|
||||
|
||||
allowed_fp: List[int] = []
|
||||
if fp_ids:
|
||||
for fid in fp_ids:
|
||||
try:
|
||||
_framework_access(cur, fid, profile_id, role)
|
||||
allowed_fp.append(fid)
|
||||
except HTTPException:
|
||||
pass
|
||||
|
||||
allowed_mod: List[int] = []
|
||||
if mod_ids:
|
||||
for mid in mod_ids:
|
||||
try:
|
||||
_module_access(cur, mid, profile_id, role)
|
||||
allowed_mod.append(mid)
|
||||
except HTTPException:
|
||||
pass
|
||||
|
||||
summaries = _merge_batch_summaries(
|
||||
cur,
|
||||
bundle=bundle,
|
||||
allowed_fp=allowed_fp,
|
||||
allowed_mod=allowed_mod,
|
||||
)
|
||||
ref_by_skill = {}
|
||||
for t in ("framework_program", "training_module", "progression_graph"):
|
||||
ref_by_skill.update(corpus_for_artifact_type(bundle, t).get("ref_by_skill") or {})
|
||||
|
||||
skill_ids_seen: set[int] = set()
|
||||
for summary in summaries.values():
|
||||
for sk in summary.get("skills") or []:
|
||||
if sk.get("skill_id") is not None:
|
||||
skill_ids_seen.add(int(sk["skill_id"]))
|
||||
|
||||
club_best_subset = {
|
||||
str(sid): ref_by_skill[sid]
|
||||
for sid in skill_ids_seen
|
||||
if sid in ref_by_skill
|
||||
}
|
||||
|
||||
return {
|
||||
"reference_scale_by_type": {
|
||||
t: reference_scale_meta(
|
||||
corpus_for_artifact_type(bundle, t),
|
||||
t,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
for t in ("framework_program", "training_module", "progression_graph")
|
||||
},
|
||||
"club_best_by_skill": club_best_subset,
|
||||
"summaries": summaries,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/skill-discovery/suggestions")
|
||||
def skill_discovery_suggestions(
|
||||
skill_ids: str = Query(..., description="Komma-getrennte skill-IDs"),
|
||||
types: Optional[str] = Query(
|
||||
default="framework_program,training_module,progression_graph",
|
||||
description="Artefakttypen, komma-getrennt",
|
||||
),
|
||||
limit: int = Query(default=20, ge=1, le=50),
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""
|
||||
Findet Bibliotheksartefakte, deren Übungs-Fähigkeiten-Profil die gewünschten Fähigkeiten stark abdeckt.
|
||||
"""
|
||||
wanted = _parse_skill_ids_param(skill_ids)
|
||||
if not wanted:
|
||||
raise HTTPException(status_code=400, detail="skill_ids ist Pflicht (mindestens eine ID)")
|
||||
|
||||
type_set = {t.strip() for t in (types or "").split(",") if t.strip()}
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
planning_bundle = _load_planning_corpus(cur, tenant)
|
||||
fw_ref = corpus_for_artifact_type(planning_bundle, "framework_program")["max_by_skill"]
|
||||
mod_ref = corpus_for_artifact_type(planning_bundle, "training_module")["max_by_skill"]
|
||||
graph_ref = corpus_for_artifact_type(planning_bundle, "progression_graph")["max_by_skill"]
|
||||
|
||||
if "framework_program" in type_set:
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="fp",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT fp.id, fp.title
|
||||
FROM training_framework_programs fp
|
||||
WHERE ({vis_clause})
|
||||
ORDER BY fp.updated_at DESC NULLS LAST
|
||||
LIMIT 80
|
||||
""",
|
||||
vis_params,
|
||||
)
|
||||
for fp_row in cur.fetchall():
|
||||
fid = int(fp_row["id"])
|
||||
try:
|
||||
_framework_access(cur, fid, profile_id, role)
|
||||
except HTTPException:
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tu.id
|
||||
FROM training_framework_slots s
|
||||
INNER JOIN training_units tu ON tu.framework_slot_id = s.id
|
||||
WHERE s.framework_program_id = %s
|
||||
""",
|
||||
(fid,),
|
||||
)
|
||||
occ: List[ExerciseOccurrence] = []
|
||||
for u in cur.fetchall():
|
||||
occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
|
||||
if not occ:
|
||||
continue
|
||||
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=fw_ref)
|
||||
match = match_score_for_skill_ids(prof, wanted)
|
||||
if match["match_weight"] <= 0:
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"artifact_type": "framework_program",
|
||||
"artifact_id": fid,
|
||||
"artifact_title": fp_row["title"],
|
||||
"path": f"/planning/framework-programs/{fid}",
|
||||
"match": match,
|
||||
"skill_profile_summary": {
|
||||
"total_score": prof.get("total_score"),
|
||||
"top_by_category": top_categories_summary(prof),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if "training_module" in type_set:
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="m",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT m.id, m.title
|
||||
FROM training_modules m
|
||||
WHERE ({vis_clause})
|
||||
ORDER BY m.updated_at DESC NULLS LAST
|
||||
LIMIT 80
|
||||
""",
|
||||
vis_params,
|
||||
)
|
||||
for m_row in cur.fetchall():
|
||||
mid = int(m_row["id"])
|
||||
try:
|
||||
_module_access(cur, mid, profile_id, role)
|
||||
except HTTPException:
|
||||
continue
|
||||
occ = collect_module_exercise_occurrences(cur, mid)
|
||||
if not occ:
|
||||
continue
|
||||
prof = profile_for_occurrences(cur, occ, reference_max_by_skill=mod_ref)
|
||||
match = match_score_for_skill_ids(prof, wanted)
|
||||
if match["match_weight"] <= 0:
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"artifact_type": "training_module",
|
||||
"artifact_id": mid,
|
||||
"artifact_title": m_row["title"],
|
||||
"path": f"/planning/training-modules/{mid}",
|
||||
"match": match,
|
||||
"skill_profile_summary": {
|
||||
"total_score": prof.get("total_score"),
|
||||
"top_by_category": top_categories_summary(prof),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if "progression_graph" in type_set:
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="g",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT g.id, g.name
|
||||
FROM exercise_progression_graphs g
|
||||
WHERE ({vis_clause})
|
||||
ORDER BY g.updated_at DESC NULLS LAST
|
||||
LIMIT 80
|
||||
""",
|
||||
vis_params,
|
||||
)
|
||||
for g_row in cur.fetchall():
|
||||
gid = int(g_row["id"])
|
||||
try:
|
||||
_require_graph_read(cur, gid, profile_id, role)
|
||||
except HTTPException:
|
||||
continue
|
||||
occ = collect_progression_graph_exercise_occurrences(cur, gid)
|
||||
if not occ:
|
||||
continue
|
||||
prof = profile_for_occurrences(
|
||||
cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES,
|
||||
reference_max_by_skill=graph_ref,
|
||||
)
|
||||
match = match_score_for_skill_ids(prof, wanted)
|
||||
if match["match_weight"] <= 0:
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"artifact_type": "progression_graph",
|
||||
"artifact_id": gid,
|
||||
"artifact_title": g_row["name"],
|
||||
"path": None,
|
||||
"match": match,
|
||||
"skill_profile_summary": {
|
||||
"total_score": prof.get("total_score"),
|
||||
"top_by_category": top_categories_summary(prof),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
results.sort(
|
||||
key=lambda x: -float(x.get("match", {}).get("match_score") or x.get("match", {}).get("match_weight") or 0),
|
||||
)
|
||||
return {
|
||||
"skill_ids": wanted,
|
||||
"types": sorted(type_set),
|
||||
"suggestions": results[:limit],
|
||||
}
|
||||
|
||||
|
||||
def _top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]:
|
||||
"""Kurzliste Top-Fähigkeit je Unterkategorie für Discovery-Treffer."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for mc in profile.get("by_main_category") or []:
|
||||
for cat in mc.get("categories") or []:
|
||||
top = cat.get("top_skill")
|
||||
if not top:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"main_category_name": mc.get("main_category_name"),
|
||||
"category_name": cat.get("category_name"),
|
||||
"skill_id": top.get("skill_id"),
|
||||
"skill_name": top.get("skill_name"),
|
||||
"score": top.get("score") or top.get("weight"),
|
||||
}
|
||||
)
|
||||
if len(out) >= limit:
|
||||
return out
|
||||
return out
|
||||
|
||||
|
||||
def _parse_id_list(raw: Any, *, max_count: int = 120) -> List[int]:
|
||||
if not raw:
|
||||
return []
|
||||
if not isinstance(raw, list):
|
||||
raise HTTPException(status_code=400, detail="ID-Listen müssen Arrays sein")
|
||||
out: List[int] = []
|
||||
for item in raw:
|
||||
try:
|
||||
n = int(item)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="Ungültige ID in Liste") from None
|
||||
if n > 0 and n not in out:
|
||||
out.append(n)
|
||||
if len(out) >= max_count:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _load_planning_corpus(cur, tenant: TenantContext) -> Dict[str, Any]:
|
||||
return compute_planning_corpus_by_type(
|
||||
cur,
|
||||
profile_id=tenant.profile_id,
|
||||
role=tenant.global_role,
|
||||
effective_club_id=tenant.effective_club_id,
|
||||
)
|
||||
|
||||
|
||||
def _enrich_profile_club_best(
|
||||
profile: Dict[str, Any],
|
||||
ref_by_skill: Dict[int, Dict[str, Any]],
|
||||
artifact_type: Optional[str] = None,
|
||||
artifact_id: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Hängt Vereins-Referenz-Artefakt an Fähigkeiten an (wenn nicht selbst Spitze)."""
|
||||
if not profile or not ref_by_skill:
|
||||
return
|
||||
|
||||
def attach(sk: Optional[Dict[str, Any]]) -> None:
|
||||
if not sk or sk.get("skill_id") is None:
|
||||
return
|
||||
sid = int(sk["skill_id"])
|
||||
ref = ref_by_skill.get(sid)
|
||||
if not ref:
|
||||
return
|
||||
if (
|
||||
artifact_type
|
||||
and artifact_id is not None
|
||||
and ref.get("artifact_type") == artifact_type
|
||||
and int(ref.get("artifact_id") or 0) == int(artifact_id)
|
||||
):
|
||||
return
|
||||
w = float(sk.get("weight") or 0)
|
||||
if w < float(ref.get("weight") or 0) - 0.01:
|
||||
sk["club_best"] = ref
|
||||
|
||||
for sk in profile.get("skills") or []:
|
||||
attach(sk)
|
||||
for mc in profile.get("by_main_category") or []:
|
||||
for cat in mc.get("categories") or []:
|
||||
attach(cat.get("top_skill"))
|
||||
|
||||
|
||||
def _empty_profile() -> Dict[str, Any]:
|
||||
return compute_skill_profile([], {})
|
||||
|
||||
|
||||
def _summarize_framework_program(
|
||||
cur,
|
||||
framework_id: int,
|
||||
ref_max: Dict[int, float],
|
||||
ref_by_skill: Dict[int, Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tu.id
|
||||
FROM training_framework_slots s
|
||||
INNER JOIN training_units tu ON tu.framework_slot_id = s.id
|
||||
WHERE s.framework_program_id = %s
|
||||
""",
|
||||
(int(framework_id),),
|
||||
)
|
||||
occ: List[ExerciseOccurrence] = []
|
||||
for u in cur.fetchall():
|
||||
occ.extend(collect_unit_exercise_occurrences(cur, int(u["id"])))
|
||||
prof = (
|
||||
profile_for_occurrences(cur, occ, reference_max_by_skill=ref_max)
|
||||
if occ
|
||||
else _empty_profile()
|
||||
)
|
||||
_enrich_profile_club_best(prof, ref_by_skill, "framework_program", int(framework_id))
|
||||
return compact_profile_summary(prof, ref_by_skill)
|
||||
|
||||
|
||||
def _summarize_training_module(
|
||||
cur,
|
||||
module_id: int,
|
||||
ref_max: Dict[int, float],
|
||||
ref_by_skill: Dict[int, Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
occ = collect_module_exercise_occurrences(cur, int(module_id))
|
||||
prof = (
|
||||
profile_for_occurrences(cur, occ, reference_max_by_skill=ref_max)
|
||||
if occ
|
||||
else _empty_profile()
|
||||
)
|
||||
_enrich_profile_club_best(prof, ref_by_skill, "training_module", int(module_id))
|
||||
return compact_profile_summary(prof, ref_by_skill)
|
||||
|
||||
|
||||
def _merge_batch_summaries(
|
||||
cur,
|
||||
*,
|
||||
bundle: Dict[str, Any],
|
||||
allowed_fp: List[int],
|
||||
allowed_mod: List[int],
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""Summaries für angeforderte IDs — Referenz je Planungs-Kontext (Typ getrennt)."""
|
||||
fw_tc = corpus_for_artifact_type(bundle, "framework_program")
|
||||
mod_tc = corpus_for_artifact_type(bundle, "training_module")
|
||||
fw_cached = fw_tc.get("artifact_summaries") or {}
|
||||
mod_cached = mod_tc.get("artifact_summaries") or {}
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
fw_ref_max = fw_tc["max_by_skill"]
|
||||
fw_ref_by = fw_tc["ref_by_skill"]
|
||||
missing_fp = [fid for fid in allowed_fp if f"framework_program:{fid}" not in fw_cached]
|
||||
if missing_fp:
|
||||
occ_map = batch_framework_occurrences_by_id(cur, missing_fp)
|
||||
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
|
||||
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
|
||||
profiles = batch_compute_profiles(
|
||||
occ_map, skills_map, reference_max_by_skill=fw_ref_max
|
||||
)
|
||||
for fid in missing_fp:
|
||||
key = f"framework_program:{fid}"
|
||||
prof = profiles.get(fid) or _empty_profile()
|
||||
_enrich_profile_club_best(prof, fw_ref_by, "framework_program", fid)
|
||||
out[key] = compact_profile_summary(prof, fw_ref_by)
|
||||
|
||||
mod_ref_max = mod_tc["max_by_skill"]
|
||||
mod_ref_by = mod_tc["ref_by_skill"]
|
||||
missing_mod = [mid for mid in allowed_mod if f"training_module:{mid}" not in mod_cached]
|
||||
if missing_mod:
|
||||
occ_map = batch_module_occurrences_by_id(cur, missing_mod)
|
||||
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
|
||||
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
|
||||
profiles = batch_compute_profiles(
|
||||
occ_map, skills_map, reference_max_by_skill=mod_ref_max
|
||||
)
|
||||
for mid in missing_mod:
|
||||
key = f"training_module:{mid}"
|
||||
prof = profiles.get(mid) or _empty_profile()
|
||||
_enrich_profile_club_best(prof, mod_ref_by, "training_module", mid)
|
||||
out[key] = compact_profile_summary(prof, mod_ref_by)
|
||||
|
||||
for fid in allowed_fp:
|
||||
key = f"framework_program:{fid}"
|
||||
if key in fw_cached:
|
||||
out[key] = fw_cached[key]
|
||||
elif key not in out:
|
||||
out[key] = _summarize_framework_program(cur, fid, fw_ref_max, fw_ref_by)
|
||||
|
||||
for mid in allowed_mod:
|
||||
key = f"training_module:{mid}"
|
||||
if key in mod_cached:
|
||||
out[key] = mod_cached[key]
|
||||
elif key not in out:
|
||||
out[key] = _summarize_training_module(cur, mid, mod_ref_max, mod_ref_by)
|
||||
|
||||
return out
|
||||
985
backend/skill_scoring.py
Normal file
985
backend/skill_scoring.py
Normal file
|
|
@ -0,0 +1,985 @@
|
|||
"""
|
||||
Gewichtetes Fähigkeiten-Scoring aus Übungsvorkommen (Phase 3, regelbasiert).
|
||||
|
||||
Aggregiert exercise_skills über alle Übungen eines Artefakts mit Gewichten aus:
|
||||
geplanter Dauer, Vorkommen, Intensität (Nutzeneinschätzung) und Stufen-Spanne (von/bis).
|
||||
|
||||
is_primary wird bewusst nicht genutzt (perspektivabhängig). development_contribution ist
|
||||
in der UI nicht gepflegt und wird ignoriert.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
|
||||
|
||||
|
||||
DEFAULT_ITEM_MINUTES = 8
|
||||
GRAPH_DEFAULT_ITEM_MINUTES = 10
|
||||
|
||||
_INTENSITY_MULT = {
|
||||
"niedrig": 0.85,
|
||||
"low": 0.85,
|
||||
"mittel": 1.0,
|
||||
"medium": 1.0,
|
||||
"hoch": 1.2,
|
||||
"high": 1.2,
|
||||
}
|
||||
|
||||
# Synchron zu backend/routers/exercises.py _EXERCISE_SKILL_LEVEL_RANK_SQL / skillLevels.js
|
||||
_LEVEL_RANK = {
|
||||
"basis": 1,
|
||||
"grundlagen": 2,
|
||||
"aufbau": 3,
|
||||
"fortgeschritten": 4,
|
||||
"optimierung": 5,
|
||||
"einsteiger": 1,
|
||||
"experte": 5,
|
||||
"1": 1,
|
||||
"2": 2,
|
||||
"3": 3,
|
||||
"4": 4,
|
||||
"5": 5,
|
||||
}
|
||||
|
||||
|
||||
def _level_rank(value: Optional[str]) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
key = str(value).strip().lower()
|
||||
if not key:
|
||||
return None
|
||||
rank = _LEVEL_RANK.get(key)
|
||||
return rank if rank is not None else None
|
||||
|
||||
|
||||
def _level_range_multiplier(
|
||||
required_level: Optional[str] = None,
|
||||
target_level: Optional[str] = None,
|
||||
) -> float:
|
||||
"""
|
||||
Stufen-Spanne (von/bis): breitere und höhere Entwicklungsstufen → etwas höheres Gewicht.
|
||||
Fehlen beide Angaben: neutral (1.0).
|
||||
"""
|
||||
rr = _level_rank(required_level)
|
||||
rt = _level_rank(target_level)
|
||||
if rr is None and rt is None:
|
||||
return 1.0
|
||||
if rr is None:
|
||||
rr = rt
|
||||
if rt is None:
|
||||
rt = rr
|
||||
if rr > rt:
|
||||
rr, rt = rt, rr
|
||||
span = max(1, min(5, rt - rr + 1))
|
||||
midpoint = (rr + rt) / 2.0
|
||||
span_mult = 0.92 + 0.04 * span
|
||||
depth_mult = 0.95 + 0.025 * midpoint
|
||||
return span_mult * depth_mult
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExerciseOccurrence:
|
||||
exercise_id: int
|
||||
planned_duration_min: Optional[int] = None
|
||||
"""Optional label for UI (e.g. slot title)."""
|
||||
context_label: Optional[str] = None
|
||||
|
||||
|
||||
def _item_base_minutes(planned: Optional[int], default: int = DEFAULT_ITEM_MINUTES) -> float:
|
||||
if planned is not None:
|
||||
try:
|
||||
m = int(planned)
|
||||
if m > 0:
|
||||
return float(m)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return float(default)
|
||||
|
||||
|
||||
def _skill_link_multiplier(
|
||||
*,
|
||||
intensity: Optional[str] = None,
|
||||
required_level: Optional[str] = None,
|
||||
target_level: Optional[str] = None,
|
||||
) -> float:
|
||||
mult = 1.0
|
||||
if intensity:
|
||||
key = str(intensity).strip().lower()
|
||||
mult *= _INTENSITY_MULT.get(key, 1.0)
|
||||
mult *= _level_range_multiplier(required_level, target_level)
|
||||
return mult
|
||||
|
||||
|
||||
def _round2(val: float) -> float:
|
||||
return round(val, 2)
|
||||
|
||||
|
||||
def _build_by_main_category(skills_out: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Hierarchie Hauptkategorie → Unterkategorie, je Top-Fähigkeit nach absolutem Gewicht."""
|
||||
main_map: Dict[int, Dict[str, Any]] = {}
|
||||
|
||||
for sk in skills_out:
|
||||
mc_id = int(sk.get("main_category_id") or 0)
|
||||
mc_name = (sk.get("main_category_name") or "").strip() or "—"
|
||||
cat_id = int(sk.get("category_id") or 0)
|
||||
cat_name = (sk.get("category_name") or sk.get("category") or "").strip() or "—"
|
||||
|
||||
if mc_id not in main_map:
|
||||
main_map[mc_id] = {
|
||||
"main_category_id": mc_id if mc_id else None,
|
||||
"main_category_name": mc_name,
|
||||
"weight": 0.0,
|
||||
"categories": {},
|
||||
}
|
||||
main = main_map[mc_id]
|
||||
main["weight"] += float(sk.get("weight") or 0)
|
||||
|
||||
if cat_id not in main["categories"]:
|
||||
main["categories"][cat_id] = {
|
||||
"category_id": cat_id if cat_id else None,
|
||||
"category_name": cat_name,
|
||||
"weight": 0.0,
|
||||
"skills": [],
|
||||
}
|
||||
cat = main["categories"][cat_id]
|
||||
cat["weight"] += float(sk.get("weight") or 0)
|
||||
cat["skills"].append(sk)
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
for mc in sorted(main_map.values(), key=lambda x: (-x["weight"], x.get("main_category_name") or "")):
|
||||
cats_out: List[Dict[str, Any]] = []
|
||||
for cat in sorted(
|
||||
mc["categories"].values(),
|
||||
key=lambda x: (-x["weight"], x.get("category_name") or ""),
|
||||
):
|
||||
cat_skills = sorted(
|
||||
cat["skills"],
|
||||
key=lambda x: (-float(x.get("weight") or 0), x.get("skill_name") or ""),
|
||||
)
|
||||
top = cat_skills[0] if cat_skills else None
|
||||
cats_out.append(
|
||||
{
|
||||
"category_id": cat["category_id"],
|
||||
"category_name": cat["category_name"],
|
||||
"weight": _round2(cat["weight"]),
|
||||
"skills_count": len(cat_skills),
|
||||
"top_skill": top,
|
||||
}
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
"main_category_id": mc["main_category_id"],
|
||||
"main_category_name": mc["main_category_name"],
|
||||
"weight": _round2(mc["weight"]),
|
||||
"categories": cats_out,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _club_universal_percent(
|
||||
weight: float,
|
||||
corpus_ref: float,
|
||||
) -> tuple[Optional[float], bool]:
|
||||
"""
|
||||
Anteil am Vereins-Maximum (max. 100 %).
|
||||
effective_ref = max(Korpus-Max, eigenes Gewicht) — verhindert Werte >100 %,
|
||||
wenn das Artefakt stärker ist als der bisherige Vereins-Vergleich (z. B. official).
|
||||
"""
|
||||
w = float(weight or 0)
|
||||
ref = float(corpus_ref or 0)
|
||||
if w <= 0:
|
||||
return None, False
|
||||
effective_ref = max(ref, w)
|
||||
pct = min(100.0, w / effective_ref * 100.0)
|
||||
is_best = ref <= 0 or w >= ref - 0.01
|
||||
return _round2(pct), is_best
|
||||
|
||||
|
||||
def _apply_reference_universal_percent(
|
||||
skills_out: List[Dict[str, Any]],
|
||||
reference_max_by_skill: Optional[Dict[int, float]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Stärke relativ zum Vereins-Maximum je Fähigkeit (gecappt auf 100 %).
|
||||
"""
|
||||
if not reference_max_by_skill:
|
||||
for sk in skills_out:
|
||||
sk["universal_percent"] = None
|
||||
sk["is_club_best_for_skill"] = False
|
||||
return
|
||||
for sk in skills_out:
|
||||
sid = int(sk["skill_id"])
|
||||
ref = float(reference_max_by_skill.get(sid) or 0)
|
||||
w = float(sk.get("weight") or 0)
|
||||
pct, is_best = _club_universal_percent(w, ref)
|
||||
sk["universal_percent"] = pct
|
||||
sk["is_club_best_for_skill"] = is_best
|
||||
|
||||
|
||||
def compute_skill_profile(
|
||||
occurrences: Sequence[ExerciseOccurrence],
|
||||
skill_rows_by_exercise: Dict[int, List[Dict[str, Any]]],
|
||||
*,
|
||||
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
|
||||
reference_max_by_skill: Optional[Dict[int, float]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Erzeugt ein normalisiertes Fähigkeiten-Profil aus Übungsvorkommen und exercise_skills.
|
||||
"""
|
||||
exercise_meta: Dict[int, Dict[str, Any]] = defaultdict(
|
||||
lambda: {"occurrence_count": 0, "minutes": 0.0, "context_labels": []}
|
||||
)
|
||||
total_occurrences = 0
|
||||
for occ in occurrences or []:
|
||||
eid = int(occ.exercise_id)
|
||||
mins = _item_base_minutes(occ.planned_duration_min, default_item_minutes)
|
||||
exercise_meta[eid]["occurrence_count"] += 1
|
||||
exercise_meta[eid]["minutes"] += mins
|
||||
total_occurrences += 1
|
||||
if occ.context_label and occ.context_label not in exercise_meta[eid]["context_labels"]:
|
||||
exercise_meta[eid]["context_labels"].append(occ.context_label)
|
||||
|
||||
skill_acc: Dict[int, Dict[str, Any]] = {}
|
||||
total_weight = 0.0
|
||||
exercises_with_skills: set[int] = set()
|
||||
|
||||
for eid, meta in exercise_meta.items():
|
||||
links = skill_rows_by_exercise.get(eid) or []
|
||||
if not links:
|
||||
continue
|
||||
exercises_with_skills.add(eid)
|
||||
occ_count = meta["occurrence_count"]
|
||||
minutes_per_occ = meta["minutes"] / occ_count if occ_count else float(default_item_minutes)
|
||||
|
||||
for link in links:
|
||||
sid = link.get("skill_id")
|
||||
if sid is None:
|
||||
continue
|
||||
sid = int(sid)
|
||||
link_mult = _skill_link_multiplier(
|
||||
intensity=link.get("intensity"),
|
||||
required_level=link.get("required_level"),
|
||||
target_level=link.get("target_level"),
|
||||
)
|
||||
contribution = minutes_per_occ * occ_count * link_mult
|
||||
if contribution <= 0:
|
||||
continue
|
||||
|
||||
if sid not in skill_acc:
|
||||
skill_acc[sid] = {
|
||||
"skill_id": sid,
|
||||
"skill_name": link.get("skill_name") or f"Fähigkeit #{sid}",
|
||||
"category": link.get("category_name") or link.get("category"),
|
||||
"category_id": link.get("category_id"),
|
||||
"category_name": link.get("category_name") or link.get("category"),
|
||||
"main_category_id": link.get("main_category_id"),
|
||||
"main_category_name": link.get("main_category_name"),
|
||||
"focus_areas": link.get("focus_areas"),
|
||||
"weight": 0.0,
|
||||
"occurrence_count": 0,
|
||||
"exercises": {},
|
||||
}
|
||||
acc = skill_acc[sid]
|
||||
acc["weight"] += contribution
|
||||
acc["occurrence_count"] += occ_count
|
||||
ex_key = str(eid)
|
||||
if ex_key not in acc["exercises"]:
|
||||
acc["exercises"][ex_key] = {
|
||||
"exercise_id": eid,
|
||||
"title": link.get("exercise_title") or f"Übung #{eid}",
|
||||
"weight": 0.0,
|
||||
"occurrence_count": occ_count,
|
||||
}
|
||||
acc["exercises"][ex_key]["weight"] += contribution
|
||||
total_weight += contribution
|
||||
|
||||
skills_out: List[Dict[str, Any]] = []
|
||||
for sid, acc in skill_acc.items():
|
||||
share = (acc["weight"] / total_weight * 100.0) if total_weight > 0 else 0.0
|
||||
ex_list = sorted(
|
||||
acc["exercises"].values(),
|
||||
key=lambda x: (-x["weight"], x.get("title") or ""),
|
||||
)[:8]
|
||||
for ex in ex_list:
|
||||
ex["weight"] = _round2(ex["weight"])
|
||||
if total_weight > 0:
|
||||
ex["share_percent"] = _round2(ex["weight"] / total_weight * 100.0)
|
||||
else:
|
||||
ex["share_percent"] = 0.0
|
||||
skills_out.append(
|
||||
{
|
||||
"skill_id": sid,
|
||||
"skill_name": acc["skill_name"],
|
||||
"category": acc.get("category_name") or acc.get("category"),
|
||||
"category_id": acc.get("category_id"),
|
||||
"category_name": acc.get("category_name") or acc.get("category"),
|
||||
"main_category_id": acc.get("main_category_id"),
|
||||
"main_category_name": acc.get("main_category_name"),
|
||||
"focus_areas": acc.get("focus_areas"),
|
||||
"weight": _round2(acc["weight"]),
|
||||
"score": _round2(acc["weight"]),
|
||||
"artifact_share_percent": _round2(share),
|
||||
"share_percent": _round2(share),
|
||||
"occurrence_count": acc["occurrence_count"],
|
||||
"top_exercises": ex_list,
|
||||
}
|
||||
)
|
||||
skills_out.sort(key=lambda x: (-x["weight"], x.get("skill_name") or ""))
|
||||
|
||||
_apply_reference_universal_percent(skills_out, reference_max_by_skill)
|
||||
|
||||
by_main_category = _build_by_main_category(skills_out)
|
||||
for mc in by_main_category:
|
||||
for cat in mc.get("categories") or []:
|
||||
top = cat.get("top_skill")
|
||||
if top and reference_max_by_skill:
|
||||
sid = int(top["skill_id"])
|
||||
ref = float(reference_max_by_skill.get(sid) or 0)
|
||||
pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref)
|
||||
top["universal_percent"] = pct
|
||||
top["is_club_best_for_skill"] = is_best
|
||||
|
||||
unique_exercises = len(exercise_meta)
|
||||
return {
|
||||
"computed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"scoring_version": "1.2",
|
||||
"score_unit": "weighted_minutes",
|
||||
"score_unit_label": "Trainingsgewicht (gewichtete Minuten, über Programme vergleichbar)",
|
||||
"total_weight": _round2(total_weight),
|
||||
"total_score": _round2(total_weight),
|
||||
"exercise_occurrence_count": total_occurrences,
|
||||
"distinct_exercise_count": unique_exercises,
|
||||
"exercises_with_skills_count": len(exercises_with_skills),
|
||||
"skills": skills_out,
|
||||
"by_main_category": by_main_category,
|
||||
"has_reference_scale": bool(reference_max_by_skill),
|
||||
}
|
||||
|
||||
|
||||
def fetch_exercise_skills_bulk(
|
||||
cur, exercise_ids: Iterable[int]
|
||||
) -> Dict[int, List[Dict[str, Any]]]:
|
||||
ids = sorted({int(x) for x in exercise_ids if x})
|
||||
if not ids:
|
||||
return {}
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT es.exercise_id, es.skill_id, es.is_primary, es.intensity,
|
||||
es.development_contribution, es.required_level, es.target_level,
|
||||
s.name AS skill_name, s.category,
|
||||
sc.id AS category_id, sc.name AS category_name,
|
||||
mc.id AS main_category_id, mc.name AS main_category_name,
|
||||
s.focus_areas,
|
||||
e.title AS exercise_title
|
||||
FROM exercise_skills es
|
||||
JOIN skills s ON s.id = es.skill_id
|
||||
LEFT JOIN skill_categories sc ON sc.id = s.category_id
|
||||
LEFT JOIN skill_main_categories mc ON mc.id = COALESCE(s.main_category_id, sc.main_category_id)
|
||||
JOIN exercises e ON e.id = es.exercise_id
|
||||
WHERE es.exercise_id IN ({ph})
|
||||
AND (s.status = 'active' OR s.status IS NULL)
|
||||
ORDER BY es.exercise_id, s.name, es.skill_id
|
||||
""",
|
||||
ids,
|
||||
)
|
||||
out: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for row in cur.fetchall():
|
||||
d = dict(row)
|
||||
eid = int(d["exercise_id"])
|
||||
fa = d.get("focus_areas")
|
||||
if fa is not None and not isinstance(fa, list):
|
||||
try:
|
||||
import json
|
||||
|
||||
fa = json.loads(fa) if isinstance(fa, str) else fa
|
||||
except Exception:
|
||||
fa = []
|
||||
d["focus_areas"] = fa if isinstance(fa, list) else []
|
||||
out[eid].append(d)
|
||||
return dict(out)
|
||||
|
||||
|
||||
def collect_unit_exercise_occurrences(cur, unit_id: int) -> List[ExerciseOccurrence]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT tusi.exercise_id, tusi.planned_duration_min
|
||||
FROM training_unit_section_items tusi
|
||||
INNER JOIN training_unit_sections tus ON tus.id = tusi.section_id
|
||||
WHERE tus.training_unit_id = %s
|
||||
AND tusi.item_type = 'exercise'
|
||||
AND tusi.exercise_id IS NOT NULL
|
||||
ORDER BY tus.order_index, tusi.order_index
|
||||
""",
|
||||
(int(unit_id),),
|
||||
)
|
||||
return [
|
||||
ExerciseOccurrence(
|
||||
exercise_id=int(r["exercise_id"]),
|
||||
planned_duration_min=r.get("planned_duration_min"),
|
||||
)
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
|
||||
def collect_module_exercise_occurrences(cur, module_id: int) -> List[ExerciseOccurrence]:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT exercise_id, planned_duration_min
|
||||
FROM training_module_items
|
||||
WHERE module_id = %s
|
||||
AND item_type = 'exercise'
|
||||
AND exercise_id IS NOT NULL
|
||||
ORDER BY order_index
|
||||
""",
|
||||
(int(module_id),),
|
||||
)
|
||||
return [
|
||||
ExerciseOccurrence(
|
||||
exercise_id=int(r["exercise_id"]),
|
||||
planned_duration_min=r.get("planned_duration_min"),
|
||||
)
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
|
||||
def collect_progression_graph_exercise_occurrences(cur, graph_id: int) -> List[ExerciseOccurrence]:
|
||||
"""Jedes Vorkommen als from- oder to-Endpunkt einer Kante zählt (ohne Dauer → Default)."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT from_exercise_id AS exercise_id FROM exercise_progression_edges WHERE graph_id = %s
|
||||
UNION ALL
|
||||
SELECT to_exercise_id AS exercise_id FROM exercise_progression_edges WHERE graph_id = %s
|
||||
""",
|
||||
(int(graph_id), int(graph_id)),
|
||||
)
|
||||
return [
|
||||
ExerciseOccurrence(
|
||||
exercise_id=int(r["exercise_id"]),
|
||||
planned_duration_min=None,
|
||||
context_label=None,
|
||||
)
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
|
||||
def profile_for_occurrences(
|
||||
cur,
|
||||
occurrences: Sequence[ExerciseOccurrence],
|
||||
*,
|
||||
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
|
||||
reference_max_by_skill: Optional[Dict[int, float]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
eids = [o.exercise_id for o in occurrences]
|
||||
skills_map = fetch_exercise_skills_bulk(cur, eids)
|
||||
return compute_skill_profile(
|
||||
occurrences,
|
||||
skills_map,
|
||||
default_item_minutes=default_item_minutes,
|
||||
reference_max_by_skill=reference_max_by_skill,
|
||||
)
|
||||
|
||||
|
||||
def merge_skill_weights_into_max(
|
||||
target: Dict[int, float],
|
||||
profile: Dict[str, Any],
|
||||
) -> None:
|
||||
for sk in profile.get("skills") or []:
|
||||
sid = int(sk["skill_id"])
|
||||
w = float(sk.get("weight") or 0)
|
||||
if w > target.get(sid, 0.0):
|
||||
target[sid] = w
|
||||
|
||||
|
||||
def merge_skill_weights_with_reference(
|
||||
max_by_skill: Dict[int, float],
|
||||
ref_by_skill: Dict[int, Dict[str, Any]],
|
||||
profile: Dict[str, Any],
|
||||
*,
|
||||
artifact_type: str,
|
||||
artifact_id: int,
|
||||
artifact_title: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Aktualisiert Vereins-Maximum je Fähigkeit inkl. Quell-Artefakt."""
|
||||
for sk in profile.get("skills") or []:
|
||||
sid = int(sk["skill_id"])
|
||||
w = float(sk.get("weight") or 0)
|
||||
if w <= 0:
|
||||
continue
|
||||
if w > max_by_skill.get(sid, 0.0):
|
||||
max_by_skill[sid] = w
|
||||
ref_by_skill[sid] = {
|
||||
"artifact_type": artifact_type,
|
||||
"artifact_id": int(artifact_id),
|
||||
"artifact_title": (artifact_title or "").strip() or None,
|
||||
"weight": _round2(w),
|
||||
}
|
||||
|
||||
|
||||
def top_categories_summary(profile: Dict[str, Any], limit: int = 6) -> List[Dict[str, Any]]:
|
||||
"""Top-Fähigkeit je Unterkategorie (kompakt für Listen/Discovery)."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for mc in profile.get("by_main_category") or []:
|
||||
for cat in mc.get("categories") or []:
|
||||
top = cat.get("top_skill")
|
||||
if not top:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"main_category_name": mc.get("main_category_name"),
|
||||
"category_name": cat.get("category_name"),
|
||||
"skill_id": top.get("skill_id"),
|
||||
"skill_name": top.get("skill_name"),
|
||||
"score": top.get("score") or top.get("weight"),
|
||||
"weight": top.get("weight"),
|
||||
"universal_percent": top.get("universal_percent"),
|
||||
"is_club_best_for_skill": top.get("is_club_best_for_skill"),
|
||||
}
|
||||
)
|
||||
if len(out) >= limit:
|
||||
return out
|
||||
return out
|
||||
|
||||
|
||||
def _apply_reference_to_profile(
|
||||
profile: Dict[str, Any],
|
||||
reference_max_by_skill: Optional[Dict[int, float]],
|
||||
) -> None:
|
||||
_apply_reference_universal_percent(profile.get("skills") or [], reference_max_by_skill)
|
||||
profile["by_main_category"] = _build_by_main_category(profile.get("skills") or [])
|
||||
for mc in profile.get("by_main_category") or []:
|
||||
for cat in mc.get("categories") or []:
|
||||
top = cat.get("top_skill")
|
||||
if top and reference_max_by_skill:
|
||||
sid = int(top["skill_id"])
|
||||
ref = float(reference_max_by_skill.get(sid) or 0)
|
||||
pct, is_best = _club_universal_percent(float(top.get("weight") or 0), ref)
|
||||
top["universal_percent"] = pct
|
||||
top["is_club_best_for_skill"] = is_best
|
||||
profile["has_reference_scale"] = bool(reference_max_by_skill)
|
||||
|
||||
|
||||
def compact_profile_summary(
|
||||
profile: Dict[str, Any],
|
||||
ref_by_skill: Optional[Dict[int, Dict[str, Any]]] = None,
|
||||
*,
|
||||
skills_limit: int = 0,
|
||||
category_limit: int = 48,
|
||||
) -> Dict[str, Any]:
|
||||
"""Leichtgewichtiges Profil für Listen — ohne Übungsdetails. skills_limit=0 → alle Fähigkeiten."""
|
||||
skills_out: List[Dict[str, Any]] = []
|
||||
for s in profile.get("skills") or []:
|
||||
sid = int(s["skill_id"])
|
||||
w = float(s.get("weight") or 0)
|
||||
entry: Dict[str, Any] = {
|
||||
"skill_id": sid,
|
||||
"skill_name": s.get("skill_name"),
|
||||
"category_name": s.get("category_name") or s.get("category"),
|
||||
"main_category_name": s.get("main_category_name"),
|
||||
"weight": s.get("weight"),
|
||||
"score": s.get("score") or s.get("weight"),
|
||||
"universal_percent": s.get("universal_percent"),
|
||||
}
|
||||
ref = (ref_by_skill or {}).get(sid)
|
||||
if ref and w < float(ref.get("weight") or 0) - 0.01:
|
||||
entry["club_best"] = ref
|
||||
if s.get("is_club_best_for_skill"):
|
||||
entry["is_club_best_for_skill"] = True
|
||||
skills_out.append(entry)
|
||||
if skills_limit > 0 and len(skills_out) >= skills_limit:
|
||||
break
|
||||
return {
|
||||
"total_score": profile.get("total_score"),
|
||||
"total_weight": profile.get("total_weight"),
|
||||
"exercise_occurrence_count": profile.get("exercise_occurrence_count"),
|
||||
"skills_count": len(profile.get("skills") or []),
|
||||
"top_by_category": top_categories_summary(profile, limit=category_limit),
|
||||
"skills": skills_out,
|
||||
}
|
||||
|
||||
|
||||
def batch_framework_occurrences_by_id(
|
||||
cur, framework_ids: Sequence[int]
|
||||
) -> Dict[int, List[ExerciseOccurrence]]:
|
||||
ids = sorted({int(x) for x in framework_ids if x})
|
||||
if not ids:
|
||||
return {}
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT s.framework_program_id,
|
||||
COALESCE(NULLIF(TRIM(s.title), ''), 'Session ' || (s.sort_order + 1)::text) AS slot_label,
|
||||
tusi.exercise_id,
|
||||
tusi.planned_duration_min
|
||||
FROM training_framework_slots s
|
||||
INNER JOIN training_units tu ON tu.framework_slot_id = s.id
|
||||
INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id
|
||||
INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id
|
||||
WHERE s.framework_program_id IN ({ph})
|
||||
AND tusi.item_type = 'exercise'
|
||||
AND tusi.exercise_id IS NOT NULL
|
||||
ORDER BY s.framework_program_id, s.sort_order, tus.order_index, tusi.order_index
|
||||
""",
|
||||
ids,
|
||||
)
|
||||
out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list)
|
||||
for row in cur.fetchall():
|
||||
fid = int(row["framework_program_id"])
|
||||
out[fid].append(
|
||||
ExerciseOccurrence(
|
||||
exercise_id=int(row["exercise_id"]),
|
||||
planned_duration_min=row.get("planned_duration_min"),
|
||||
context_label=row.get("slot_label"),
|
||||
)
|
||||
)
|
||||
return dict(out)
|
||||
|
||||
|
||||
def batch_module_occurrences_by_id(
|
||||
cur, module_ids: Sequence[int]
|
||||
) -> Dict[int, List[ExerciseOccurrence]]:
|
||||
ids = sorted({int(x) for x in module_ids if x})
|
||||
if not ids:
|
||||
return {}
|
||||
ph = ",".join(["%s"] * len(ids))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT module_id, exercise_id, planned_duration_min
|
||||
FROM training_module_items
|
||||
WHERE module_id IN ({ph})
|
||||
AND item_type = 'exercise'
|
||||
AND exercise_id IS NOT NULL
|
||||
ORDER BY module_id, order_index
|
||||
""",
|
||||
ids,
|
||||
)
|
||||
out: Dict[int, List[ExerciseOccurrence]] = defaultdict(list)
|
||||
for row in cur.fetchall():
|
||||
mid = int(row["module_id"])
|
||||
out[mid].append(
|
||||
ExerciseOccurrence(
|
||||
exercise_id=int(row["exercise_id"]),
|
||||
planned_duration_min=row.get("planned_duration_min"),
|
||||
)
|
||||
)
|
||||
return dict(out)
|
||||
|
||||
|
||||
def batch_compute_profiles(
|
||||
occ_by_artifact: Dict[int, List[ExerciseOccurrence]],
|
||||
skills_map: Dict[int, List[Dict[str, Any]]],
|
||||
*,
|
||||
reference_max_by_skill: Optional[Dict[int, float]] = None,
|
||||
default_item_minutes: int = DEFAULT_ITEM_MINUTES,
|
||||
) -> Dict[int, Dict[str, Any]]:
|
||||
return {
|
||||
aid: compute_skill_profile(
|
||||
occ,
|
||||
skills_map,
|
||||
default_item_minutes=default_item_minutes,
|
||||
reference_max_by_skill=reference_max_by_skill,
|
||||
)
|
||||
for aid, occ in occ_by_artifact.items()
|
||||
}
|
||||
|
||||
|
||||
def _empty_type_corpus() -> Dict[str, Any]:
|
||||
return {
|
||||
"max_by_skill": {},
|
||||
"ref_by_skill": {},
|
||||
"artifact_count": 0,
|
||||
"artifact_summaries": {},
|
||||
}
|
||||
|
||||
|
||||
def corpus_for_artifact_type(
|
||||
bundle: Dict[str, Any],
|
||||
artifact_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
by_type = bundle.get("by_type") or {}
|
||||
return by_type.get(artifact_type) or _empty_type_corpus()
|
||||
|
||||
|
||||
def _scan_artifact_type_corpus(
|
||||
cur,
|
||||
*,
|
||||
artifact_type: str,
|
||||
profile_id: int,
|
||||
role: Optional[str],
|
||||
effective_club_id: Optional[int],
|
||||
include_artifact_summaries: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""Referenz je Fähigkeit nur innerhalb eines Planungs-Kontexts (ein Artefakttyp, sichtbare Bibliothek)."""
|
||||
from tenant_context import library_content_visibility_sql
|
||||
|
||||
max_by_skill: Dict[int, float] = {}
|
||||
ref_by_skill: Dict[int, Dict[str, Any]] = {}
|
||||
artifact_count = 0
|
||||
raw_profiles: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def ingest(aid: int, title: Optional[str], prof: Dict[str, Any]) -> None:
|
||||
nonlocal artifact_count
|
||||
if not prof.get("skills"):
|
||||
return
|
||||
artifact_count += 1
|
||||
merge_skill_weights_with_reference(
|
||||
max_by_skill,
|
||||
ref_by_skill,
|
||||
prof,
|
||||
artifact_type=artifact_type,
|
||||
artifact_id=aid,
|
||||
artifact_title=title,
|
||||
)
|
||||
if include_artifact_summaries:
|
||||
raw_profiles[f"{artifact_type}:{aid}"] = prof
|
||||
|
||||
if artifact_type == "framework_program":
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="fp",
|
||||
profile_id=profile_id,
|
||||
role=role or "",
|
||||
effective_club_id=effective_club_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT fp.id, fp.title
|
||||
FROM training_framework_programs fp
|
||||
WHERE ({vis_clause})
|
||||
ORDER BY fp.updated_at DESC NULLS LAST
|
||||
""",
|
||||
vis_params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
if rows:
|
||||
ids = [int(r["id"]) for r in rows]
|
||||
titles = {int(r["id"]): r.get("title") for r in rows}
|
||||
occ_map = batch_framework_occurrences_by_id(cur, ids)
|
||||
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
|
||||
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
|
||||
profiles = batch_compute_profiles(occ_map, skills_map)
|
||||
for aid, prof in profiles.items():
|
||||
ingest(aid, titles.get(aid), prof)
|
||||
|
||||
elif artifact_type == "training_module":
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="m",
|
||||
profile_id=profile_id,
|
||||
role=role or "",
|
||||
effective_club_id=effective_club_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT m.id, m.title
|
||||
FROM training_modules m
|
||||
WHERE ({vis_clause})
|
||||
ORDER BY m.updated_at DESC NULLS LAST
|
||||
""",
|
||||
vis_params,
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
if rows:
|
||||
ids = [int(r["id"]) for r in rows]
|
||||
titles = {int(r["id"]): r.get("title") for r in rows}
|
||||
occ_map = batch_module_occurrences_by_id(cur, ids)
|
||||
all_eids = {o.exercise_id for occs in occ_map.values() for o in occs}
|
||||
skills_map = fetch_exercise_skills_bulk(cur, all_eids) if all_eids else {}
|
||||
profiles = batch_compute_profiles(occ_map, skills_map)
|
||||
for aid, prof in profiles.items():
|
||||
ingest(aid, titles.get(aid), prof)
|
||||
|
||||
elif artifact_type == "progression_graph":
|
||||
vis_clause, vis_params = library_content_visibility_sql(
|
||||
alias="g",
|
||||
profile_id=profile_id,
|
||||
role=role or "",
|
||||
effective_club_id=effective_club_id,
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT g.id, g.name
|
||||
FROM exercise_progression_graphs g
|
||||
WHERE ({vis_clause})
|
||||
ORDER BY g.updated_at DESC NULLS LAST
|
||||
""",
|
||||
vis_params,
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
gid = int(row["id"])
|
||||
occ = collect_progression_graph_exercise_occurrences(cur, gid)
|
||||
if not occ:
|
||||
continue
|
||||
prof = profile_for_occurrences(
|
||||
cur, occ, default_item_minutes=GRAPH_DEFAULT_ITEM_MINUTES
|
||||
)
|
||||
ingest(gid, row.get("name"), prof)
|
||||
|
||||
artifact_summaries: Dict[str, Dict[str, Any]] = {}
|
||||
if include_artifact_summaries and raw_profiles:
|
||||
for key, prof in raw_profiles.items():
|
||||
_apply_reference_to_profile(prof, max_by_skill)
|
||||
artifact_summaries[key] = compact_profile_summary(prof, ref_by_skill)
|
||||
|
||||
return {
|
||||
"max_by_skill": max_by_skill,
|
||||
"ref_by_skill": ref_by_skill,
|
||||
"artifact_count": artifact_count,
|
||||
"artifact_summaries": artifact_summaries,
|
||||
}
|
||||
|
||||
|
||||
def compute_planning_corpus_by_type(
|
||||
cur,
|
||||
*,
|
||||
profile_id: int,
|
||||
role: Optional[str],
|
||||
effective_club_id: Optional[int],
|
||||
include_artifact_summaries: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Referenz je Fähigkeit getrennt nach Planungs-Kontext:
|
||||
Rahmenprogramme, Trainingsmodule und Regressionspfade jeweils für sich,
|
||||
jeweils über die sichtbare Bibliothek (library_content_visibility_sql).
|
||||
"""
|
||||
by_type = {
|
||||
"framework_program": _scan_artifact_type_corpus(
|
||||
cur,
|
||||
artifact_type="framework_program",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=effective_club_id,
|
||||
include_artifact_summaries=include_artifact_summaries,
|
||||
),
|
||||
"training_module": _scan_artifact_type_corpus(
|
||||
cur,
|
||||
artifact_type="training_module",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=effective_club_id,
|
||||
include_artifact_summaries=include_artifact_summaries,
|
||||
),
|
||||
"progression_graph": _scan_artifact_type_corpus(
|
||||
cur,
|
||||
artifact_type="progression_graph",
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=effective_club_id,
|
||||
include_artifact_summaries=include_artifact_summaries,
|
||||
),
|
||||
}
|
||||
return {
|
||||
"effective_club_id": effective_club_id,
|
||||
"by_type": by_type,
|
||||
}
|
||||
|
||||
|
||||
def compute_club_corpus_reference(
|
||||
cur,
|
||||
*,
|
||||
profile_id: int,
|
||||
effective_club_id: Optional[int],
|
||||
include_artifact_summaries: bool = False,
|
||||
role: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Legacy-Hülle — merged summaries über alle Typen (vermeiden für neue Aufrufer)."""
|
||||
bundle = compute_planning_corpus_by_type(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=effective_club_id,
|
||||
include_artifact_summaries=include_artifact_summaries,
|
||||
)
|
||||
tc = corpus_for_artifact_type(bundle, "framework_program")
|
||||
merged_summaries: Dict[str, Dict[str, Any]] = {}
|
||||
for t in ("framework_program", "training_module", "progression_graph"):
|
||||
merged_summaries.update((bundle["by_type"][t].get("artifact_summaries") or {}))
|
||||
return {
|
||||
"club_id": effective_club_id,
|
||||
"max_by_skill": tc["max_by_skill"],
|
||||
"ref_by_skill": tc["ref_by_skill"],
|
||||
"artifact_count": sum(
|
||||
(bundle["by_type"][t].get("artifact_count") or 0)
|
||||
for t in bundle["by_type"]
|
||||
),
|
||||
"artifact_summaries": merged_summaries,
|
||||
}
|
||||
|
||||
|
||||
_ARTIFACT_TYPE_LABELS = {
|
||||
"framework_program": "Rahmenprogrammen",
|
||||
"training_module": "Trainingsmodulen",
|
||||
"progression_graph": "Regressionspfaden",
|
||||
}
|
||||
|
||||
|
||||
def reference_scale_meta(
|
||||
type_corpus: Dict[str, Any],
|
||||
artifact_type: str,
|
||||
*,
|
||||
effective_club_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
label = _ARTIFACT_TYPE_LABELS.get(artifact_type, artifact_type)
|
||||
return {
|
||||
"scope": "planning_peer",
|
||||
"artifact_type": artifact_type,
|
||||
"effective_club_id": effective_club_id,
|
||||
"skills_in_corpus": len(type_corpus.get("max_by_skill") or {}),
|
||||
"artifacts_scanned": type_corpus.get("artifact_count") or 0,
|
||||
"description": (
|
||||
f"Prozent = Anteil am stärksten sichtbaren Eintrag unter {label} je Fähigkeit "
|
||||
f"(nicht gemischt mit anderen Planungs-Artefakttypen)"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def compute_corpus_skill_max_weights(
|
||||
cur,
|
||||
*,
|
||||
profile_id: int,
|
||||
role: Optional[str],
|
||||
effective_club_id: Optional[int],
|
||||
limit_per_type: int = 50,
|
||||
artifact_type: str = "framework_program",
|
||||
) -> Dict[int, float]:
|
||||
"""Referenz je Fähigkeit innerhalb eines Planungs-Kontexts (Legacy-Hülle)."""
|
||||
del limit_per_type
|
||||
bundle = compute_planning_corpus_by_type(
|
||||
cur,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
effective_club_id=effective_club_id,
|
||||
)
|
||||
return corpus_for_artifact_type(bundle, artifact_type)["max_by_skill"]
|
||||
|
||||
|
||||
def match_score_for_skill_ids(profile: Dict[str, Any], skill_ids: Sequence[int]) -> Dict[str, Any]:
|
||||
"""Überlappung eines Profils mit gewünschten Fähigkeiten (für Vorschläge)."""
|
||||
wanted = {int(x) for x in skill_ids if x is not None}
|
||||
if not wanted:
|
||||
return {
|
||||
"match_weight": 0.0,
|
||||
"match_score": 0.0,
|
||||
"match_percent": 0.0,
|
||||
"artifact_focus_percent": 0.0,
|
||||
"matched_skill_ids": [],
|
||||
"matched_skills": [],
|
||||
}
|
||||
matched = []
|
||||
match_weight = 0.0
|
||||
total = float(profile.get("total_weight") or 0)
|
||||
for sk in profile.get("skills") or []:
|
||||
sid = int(sk["skill_id"])
|
||||
if sid in wanted:
|
||||
matched.append(sk)
|
||||
match_weight += float(sk.get("weight") or 0)
|
||||
artifact_focus = (match_weight / total * 100.0) if total > 0 else 0.0
|
||||
return {
|
||||
"match_weight": _round2(match_weight),
|
||||
"match_score": _round2(match_weight),
|
||||
"match_percent": _round2(artifact_focus),
|
||||
"artifact_focus_percent": _round2(artifact_focus),
|
||||
"matched_skill_ids": [int(m["skill_id"]) for m in matched],
|
||||
"matched_skills": matched,
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
125
backend/tests/test_skill_scoring.py
Normal file
125
backend/tests/test_skill_scoring.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Unit-Tests für gewichtetes Fähigkeiten-Scoring (Phase 3)."""
|
||||
from skill_scoring import (
|
||||
ExerciseOccurrence,
|
||||
compute_skill_profile,
|
||||
match_score_for_skill_ids,
|
||||
_club_universal_percent,
|
||||
_level_range_multiplier,
|
||||
_skill_link_multiplier,
|
||||
)
|
||||
|
||||
|
||||
def test_skill_link_multiplier_intensity_and_levels():
|
||||
assert _skill_link_multiplier(intensity="hoch") == 1.2
|
||||
assert _skill_link_multiplier(intensity="niedrig") == 0.85
|
||||
wide = _skill_link_multiplier(
|
||||
intensity="mittel",
|
||||
required_level="basis",
|
||||
target_level="optimierung",
|
||||
)
|
||||
narrow = _skill_link_multiplier(
|
||||
intensity="mittel",
|
||||
required_level="grundlagen",
|
||||
target_level="grundlagen",
|
||||
)
|
||||
assert wide > narrow
|
||||
|
||||
|
||||
def test_level_range_multiplier_span():
|
||||
assert _level_range_multiplier(None, None) == 1.0
|
||||
assert _level_range_multiplier("aufbau", "fortgeschritten") > _level_range_multiplier("basis", "basis")
|
||||
|
||||
|
||||
def test_compute_skill_profile_aggregates_weights():
|
||||
occurrences = [
|
||||
ExerciseOccurrence(exercise_id=1, planned_duration_min=60),
|
||||
ExerciseOccurrence(exercise_id=1, planned_duration_min=30),
|
||||
]
|
||||
skills_map = {
|
||||
1: [
|
||||
{
|
||||
"skill_id": 10,
|
||||
"skill_name": "Distanz",
|
||||
"category": "kihon",
|
||||
"category_id": 1,
|
||||
"category_name": "kihon",
|
||||
"main_category_id": 100,
|
||||
"main_category_name": "Technik",
|
||||
"intensity": "hoch",
|
||||
"required_level": "grundlagen",
|
||||
"target_level": "aufbau",
|
||||
"exercise_title": "Übung A",
|
||||
},
|
||||
{
|
||||
"skill_id": 11,
|
||||
"skill_name": "Balance",
|
||||
"category": "kihon",
|
||||
"category_id": 1,
|
||||
"category_name": "kihon",
|
||||
"main_category_id": 100,
|
||||
"main_category_name": "Technik",
|
||||
"intensity": "niedrig",
|
||||
"required_level": "basis",
|
||||
"target_level": "basis",
|
||||
"exercise_title": "Übung A",
|
||||
},
|
||||
],
|
||||
}
|
||||
profile = compute_skill_profile(occurrences, skills_map)
|
||||
assert profile["scoring_version"] == "1.2"
|
||||
assert profile["exercise_occurrence_count"] == 2
|
||||
assert profile["distinct_exercise_count"] == 1
|
||||
assert len(profile["skills"]) == 2
|
||||
assert profile["skills"][0]["skill_id"] == 10
|
||||
assert profile["total_weight"] > profile["skills"][1]["weight"]
|
||||
assert abs(sum(s["share_percent"] for s in profile["skills"]) - 100.0) < 0.1
|
||||
assert len(profile["by_main_category"]) == 1
|
||||
assert profile["by_main_category"][0]["categories"][0]["top_skill"]["skill_id"] == 10
|
||||
|
||||
|
||||
def test_universal_percent_against_corpus_max():
|
||||
occurrences = [ExerciseOccurrence(exercise_id=1, planned_duration_min=50)]
|
||||
skills_map = {
|
||||
1: [
|
||||
{
|
||||
"skill_id": 10,
|
||||
"skill_name": "Koordination",
|
||||
"category_name": "Koordination",
|
||||
"category_id": 2,
|
||||
"main_category_id": 200,
|
||||
"main_category_name": "Körper",
|
||||
},
|
||||
],
|
||||
}
|
||||
profile = compute_skill_profile(
|
||||
occurrences,
|
||||
skills_map,
|
||||
reference_max_by_skill={10: 100.0},
|
||||
)
|
||||
assert profile["has_reference_scale"] is True
|
||||
assert profile["skills"][0]["universal_percent"] == 50.0
|
||||
assert profile["skills"][0]["is_club_best_for_skill"] is False
|
||||
|
||||
|
||||
def test_club_universal_percent_capped_at_100():
|
||||
pct, is_best = _club_universal_percent(150.0, 100.0)
|
||||
assert pct == 100.0
|
||||
assert is_best is True
|
||||
pct2, _ = _club_universal_percent(72.6, 100.0)
|
||||
assert pct2 == 72.6
|
||||
|
||||
|
||||
def test_match_score_for_skill_ids():
|
||||
profile = {
|
||||
"total_weight": 100.0,
|
||||
"skills": [
|
||||
{"skill_id": 1, "skill_name": "A", "weight": 40.0},
|
||||
{"skill_id": 2, "skill_name": "B", "weight": 60.0},
|
||||
],
|
||||
}
|
||||
m = match_score_for_skill_ids(profile, [1])
|
||||
assert m["match_weight"] == 40.0
|
||||
assert m["match_score"] == 40.0
|
||||
assert m["match_percent"] == 40.0
|
||||
assert m["artifact_focus_percent"] == 40.0
|
||||
assert m["matched_skill_ids"] == [1]
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.150"
|
||||
APP_VERSION = "0.8.151"
|
||||
BUILD_DATE = "2026-05-20"
|
||||
DB_SCHEMA_VERSION = "20260520066"
|
||||
|
||||
|
|
@ -20,6 +20,7 @@ MODULE_VERSIONS = {
|
|||
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
|
||||
"groups": "0.1.0",
|
||||
"skills": "0.1.1", # DB 065 karate_relevance + relevance_level; CRUD unterstützt Felder
|
||||
"skill_profiles": "1.0.0", # Phase 3: gewichtetes Fähigkeiten-Profil + skill-discovery/suggestions
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
|
||||
"training_units": "0.4.0", # POST .../publish-to-framework: Ablauf aus geplanter Einheit → Rahmen-Slot-Blueprint
|
||||
|
|
@ -36,6 +37,15 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.151",
|
||||
"date": "2026-05-20",
|
||||
"changes": [
|
||||
"Phase 3 Fähigkeiten-Scoring: skill_scoring.py (Dauer × Vorkommen × Primär/Intensität/Beitrag)",
|
||||
"GET skill-profile für Rahmenprogramm (gesamt + pro Slot), Trainingsmodul, Progressionsgraph",
|
||||
"GET /api/skill-discovery/suggestions nach skill_ids",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.149",
|
||||
"date": "2026-05-19",
|
||||
|
|
|
|||
40
frontend/src/api/skillProfiles.js
Normal file
40
frontend/src/api/skillProfiles.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { request } from './client.js'
|
||||
|
||||
export async function getFrameworkProgramSkillProfile(frameworkId) {
|
||||
return request(`/api/training-framework-programs/${frameworkId}/skill-profile`)
|
||||
}
|
||||
|
||||
export async function getTrainingModuleSkillProfile(moduleId) {
|
||||
return request(`/api/training-modules/${moduleId}/skill-profile`)
|
||||
}
|
||||
|
||||
export async function getProgressionGraphSkillProfile(graphId) {
|
||||
return request(`/api/exercise-progression-graphs/${graphId}/skill-profile`)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number[]} skillIds
|
||||
* @param {{ types?: string, limit?: number }} opts
|
||||
*/
|
||||
export async function getSkillDiscoverySuggestions(skillIds, opts = {}) {
|
||||
const ids = (skillIds || []).filter((x) => x != null && Number(x) > 0).join(',')
|
||||
if (!ids) return { skill_ids: [], suggestions: [] }
|
||||
const params = new URLSearchParams({ skill_ids: ids })
|
||||
if (opts.types) params.set('types', opts.types)
|
||||
if (opts.limit != null) params.set('limit', String(opts.limit))
|
||||
return request(`/api/skill-discovery/suggestions?${params.toString()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-Summaries für Listen (ein Vereins-Corpus-Scan).
|
||||
* @param {{ frameworkProgramIds?: number[], trainingModuleIds?: number[] }} payload
|
||||
*/
|
||||
export async function batchSkillProfileSummaries(payload = {}) {
|
||||
return request('/api/skill-profiles/batch-summaries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
framework_program_ids: payload.frameworkProgramIds || [],
|
||||
training_module_ids: payload.trainingModuleIds || [],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
@ -2475,6 +2475,474 @@ html.modal-scroll-locked .app-main {
|
|||
}
|
||||
}
|
||||
|
||||
/* Phase 3: Fähigkeiten-Profil & Planungs-Vorschläge */
|
||||
.skill-profile {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.skill-profile--loading,
|
||||
.skill-profile--error {
|
||||
padding: 1rem 1.15rem;
|
||||
}
|
||||
.skill-profile__status {
|
||||
margin: 0;
|
||||
color: var(--text2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.skill-profile__toggle {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
width: 100%;
|
||||
padding: 0.85rem 1.1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.skill-profile__toggle-title {
|
||||
font-weight: 700;
|
||||
color: var(--text1);
|
||||
}
|
||||
.skill-profile__toggle-badge {
|
||||
font-size: 0.78rem;
|
||||
color: var(--accent-dark);
|
||||
background: var(--accent-light);
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.skill-profile__toggle-icon {
|
||||
margin-left: auto;
|
||||
color: var(--text3);
|
||||
}
|
||||
.skill-profile__body {
|
||||
padding: 0 1.1rem 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.skill-profile__hint {
|
||||
margin: 0.65rem 0 0.75rem;
|
||||
}
|
||||
.skill-profile__empty {
|
||||
margin: 0;
|
||||
color: var(--text2);
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.skill-profile__stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px 18px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text2);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.skill-profile__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.skill-profile__list--nested {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid var(--border);
|
||||
}
|
||||
.skill-profile__row-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.skill-profile__name {
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
color: var(--text1);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.skill-profile__pct {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-dark);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.skill-profile__bar-track {
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--surface2);
|
||||
overflow: hidden;
|
||||
}
|
||||
.skill-profile__bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, var(--accent) 0%, var(--accent-dark) 100%);
|
||||
min-width: 2px;
|
||||
}
|
||||
.skill-profile__meta-hint {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text3);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.skill-profile__by-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.skill-profile__main-cat {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.skill-profile__main-cat-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--text2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.skill-profile__cat-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.skill-profile__cat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.skill-profile__cat-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
}
|
||||
.skill-profile__cat-row {
|
||||
list-style: none;
|
||||
}
|
||||
.skill-profile__categories {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
.skill-profile__categories-label,
|
||||
.skill-profile__slots-label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text3);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.skill-profile__category-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.skill-profile__category-chip {
|
||||
font-size: 0.78rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface2);
|
||||
color: var(--text2);
|
||||
}
|
||||
.skill-profile__slots {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.skill-profile__slot-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.skill-profile__slot-btn {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 8px 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: var(--text1);
|
||||
}
|
||||
.skill-profile__slot-top {
|
||||
font-size: 0.82rem;
|
||||
color: var(--accent-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
.skill-profile__slot-top--muted {
|
||||
color: var(--text3);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.skill-profile-compact {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.skill-kpi-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.skill-kpi-grid--loading,
|
||||
.skill-kpi-grid--empty {
|
||||
margin: 0;
|
||||
}
|
||||
.skill-kpi-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 5.5rem;
|
||||
max-width: 8.5rem;
|
||||
padding: 5px 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
.skill-kpi-tile--highlight {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
.skill-kpi-tile--best {
|
||||
box-shadow: inset 0 0 0 1px var(--accent-dark);
|
||||
}
|
||||
.skill-kpi-tile__cat {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.skill-kpi-tile__name {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
color: var(--text1);
|
||||
line-height: 1.2;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.skill-kpi-tile__score {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: var(--text1);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.skill-kpi-tile__pct {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
.skill-profile__name-cat {
|
||||
font-weight: 500;
|
||||
color: var(--text3);
|
||||
}
|
||||
.skill-profile--embedded .skill-profile__body {
|
||||
padding: 0;
|
||||
border-top: none;
|
||||
}
|
||||
.skill-profile--embedded .skill-profile__hint {
|
||||
margin-top: 0;
|
||||
}
|
||||
.fw-prog-card__section-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.fw-prog-card__section--skills {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.fw-import-skill-grid {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.skill-profile-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 24px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.skill-profile-modal__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
cursor: pointer;
|
||||
}
|
||||
.skill-profile-modal__panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(42rem, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.1rem 1.25rem;
|
||||
margin-top: 2vh;
|
||||
}
|
||||
.skill-profile-modal__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.skill-profile-modal__title {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.skill-profile-modal__scale {
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.skill-discovery {
|
||||
padding: 1.15rem 1.25rem;
|
||||
max-width: 52rem;
|
||||
}
|
||||
.skill-discovery__title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.skill-discovery__lead {
|
||||
margin: 0 0 1rem;
|
||||
max-width: 40rem;
|
||||
}
|
||||
.skill-discovery__pick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||
gap: 8px;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface2);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.skill-discovery__pick {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.skill-discovery__pick:hover {
|
||||
background: var(--surface);
|
||||
}
|
||||
.skill-discovery__pick-name {
|
||||
font-weight: 600;
|
||||
color: var(--text1);
|
||||
}
|
||||
.skill-discovery__pick-cat {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text3);
|
||||
}
|
||||
.skill-discovery__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.skill-discovery__error {
|
||||
color: var(--danger);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.skill-discovery__results {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.skill-discovery__result {
|
||||
padding: 0.85rem 1rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.skill-discovery__result-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.skill-discovery__result-type {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text3);
|
||||
}
|
||||
.skill-discovery__result-match {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
.skill-discovery__result-title {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.skill-discovery__result-skills {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text2);
|
||||
}
|
||||
.skill-discovery__result-cats {
|
||||
list-style: none;
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text2);
|
||||
}
|
||||
.skill-discovery__result-cats li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.skill-discovery__result-cat-name {
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
}
|
||||
.skill-discovery__result-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
.skill-discovery__no-hit {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Rahmenprogramm-Editor (Vollseiten-Formular mit Action-Dock) */
|
||||
.page-form-editor__body .framework-edit {
|
||||
min-width: 0;
|
||||
|
|
@ -3618,6 +4086,12 @@ html.modal-scroll-locked .app-main {
|
|||
|
||||
.exercise-filter-modal.admin-modal-sheet {
|
||||
max-width: min(920px, calc(100dvw - 16px));
|
||||
max-height: min(92vh, 920px);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.exercise-filter-modal.admin-modal-sheet {
|
||||
max-height: min(90vh, 920px);
|
||||
}
|
||||
}
|
||||
.exercise-filter-modal .admin-modal-sheet__body.exercise-filter-modal__scroll {
|
||||
flex: 1;
|
||||
|
|
@ -5386,12 +5860,16 @@ html.modal-scroll-locked .app-main {
|
|||
border-color: var(--border2);
|
||||
}
|
||||
|
||||
/* Rahmenprogramm-Filter auf Übersichtsseite */
|
||||
.fw-prog-filter-block--list {
|
||||
/* Rahmenprogramm-/Modul-Filter auf Übersichtsseite */
|
||||
.fw-prog-filter-block--list,
|
||||
.planning-list-filter-bar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.fw-prog-filter-block--list .fw-import-filter-panel {
|
||||
margin-top: 0.75rem;
|
||||
.planning-list-filter-bar__search {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.planning-list-filter-bar .fw-import-results-bar {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* —— Rahmenprogramm-Bibliothek (Liste) —— */
|
||||
|
|
@ -5946,6 +6424,24 @@ html.modal-scroll-locked .app-main {
|
|||
max-height: min(360px, 55vh);
|
||||
overflow: auto;
|
||||
}
|
||||
.skill-tree-multiselect__panel--portal {
|
||||
position: fixed;
|
||||
right: auto;
|
||||
top: auto;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.skill-tree-multiselect__panel--portal .multiselect-combo__list {
|
||||
position: static;
|
||||
left: auto;
|
||||
right: auto;
|
||||
top: auto;
|
||||
margin: 0;
|
||||
max-height: none;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
.skill-tree {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import SkillProfilePanel from './skills/SkillProfilePanel'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||
import ExercisePickerModal from './ExercisePickerModal'
|
||||
|
|
@ -136,6 +137,9 @@ export default function ExerciseProgressionGraphPanel({
|
|||
const [editingEdgeNotes, setEditingEdgeNotes] = useState(null)
|
||||
const [notesDraft, setNotesDraft] = useState('')
|
||||
const [uiTab, setUiTab] = useState('overview')
|
||||
const [skillProfileData, setSkillProfileData] = useState(null)
|
||||
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
|
||||
const [skillProfileError, setSkillProfileError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedGraphId(null)
|
||||
|
|
@ -180,6 +184,32 @@ export default function ExerciseProgressionGraphPanel({
|
|||
}
|
||||
}, [refreshGraphs, tenantClubDepKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGraphId) {
|
||||
setSkillProfileData(null)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setSkillProfileLoading(true)
|
||||
setSkillProfileError('')
|
||||
try {
|
||||
const data = await api.getProgressionGraphSkillProfile(selectedGraphId)
|
||||
if (!cancelled) setSkillProfileData(data)
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setSkillProfileData(null)
|
||||
setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setSkillProfileLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [selectedGraphId, edges.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGraphId) {
|
||||
setEdges([])
|
||||
|
|
@ -652,6 +682,15 @@ export default function ExerciseProgressionGraphPanel({
|
|||
|
||||
{selectedGraphId && uiTab === 'overview' && (
|
||||
<>
|
||||
<SkillProfilePanel
|
||||
title="Fähigkeiten entlang des Pfads"
|
||||
hint="Alle Übungen als Knoten im Graph (Standardgewicht pro Übung; Intensität und Stufen aus der Übungsverknüpfung)."
|
||||
profile={skillProfileData?.overall}
|
||||
loading={skillProfileLoading}
|
||||
error={skillProfileError}
|
||||
defaultExpanded
|
||||
artifactType="progression_graph"
|
||||
/>
|
||||
<div className="card" style={{ marginBottom: '12px' }}>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Sequenz / Reihe anlegen</h3>
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0 }}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { collectSkillLeavesFromTree, buildSkillCatalogTree } from '../utils/skillCatalogTree'
|
||||
import SkillTreePickerPanel from './SkillTreePickerPanel'
|
||||
|
||||
const PANEL_MAX_HEIGHT = 360
|
||||
const PANEL_MIN_HEIGHT = 140
|
||||
const PANEL_Z_INDEX = 1100
|
||||
|
||||
function normId(id) {
|
||||
return String(id)
|
||||
}
|
||||
|
|
@ -17,11 +22,15 @@ export default function SkillTreeMultiSelect({
|
|||
browseLabel = '▼ Katalog',
|
||||
emptyHint = 'Keine Treffer',
|
||||
className = '',
|
||||
usePortal = true,
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [browseTree, setBrowseTree] = useState(false)
|
||||
const [panelStyle, setPanelStyle] = useState(null)
|
||||
const rootRef = useRef(null)
|
||||
const fieldRef = useRef(null)
|
||||
const panelRef = useRef(null)
|
||||
|
||||
const tree = useMemo(() => buildSkillCatalogTree(skills), [skills])
|
||||
const selectedSet = useMemo(() => new Set(value.map(normId)), [value])
|
||||
|
|
@ -56,17 +65,99 @@ export default function SkillTreeMultiSelect({
|
|||
|
||||
useEffect(() => {
|
||||
const onDoc = (e) => {
|
||||
if (!rootRef.current?.contains(e.target)) {
|
||||
setOpen(false)
|
||||
setBrowseTree(false)
|
||||
}
|
||||
const t = e.target
|
||||
if (rootRef.current?.contains(t) || panelRef.current?.contains(t)) return
|
||||
setOpen(false)
|
||||
setBrowseTree(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDoc)
|
||||
return () => document.removeEventListener('mousedown', onDoc)
|
||||
}, [])
|
||||
|
||||
const updatePanelPosition = useCallback(() => {
|
||||
const anchor = fieldRef.current
|
||||
if (!anchor) return
|
||||
const rect = anchor.getBoundingClientRect()
|
||||
const gap = 4
|
||||
const margin = 12
|
||||
const spaceBelow = window.innerHeight - rect.bottom - gap - margin
|
||||
const spaceAbove = rect.top - gap - margin
|
||||
const openUp = spaceBelow < PANEL_MIN_HEIGHT && spaceAbove > spaceBelow
|
||||
const available = Math.max(0, openUp ? spaceAbove : spaceBelow)
|
||||
const maxHeight = Math.min(PANEL_MAX_HEIGHT, Math.max(PANEL_MIN_HEIGHT, available))
|
||||
const top = openUp ? Math.max(margin, rect.top - gap - maxHeight) : rect.bottom + gap
|
||||
|
||||
setPanelStyle({
|
||||
position: 'fixed',
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
top,
|
||||
maxHeight,
|
||||
zIndex: PANEL_Z_INDEX,
|
||||
})
|
||||
}, [])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || !usePortal) {
|
||||
setPanelStyle(null)
|
||||
return undefined
|
||||
}
|
||||
updatePanelPosition()
|
||||
window.addEventListener('resize', updatePanelPosition)
|
||||
window.addEventListener('scroll', updatePanelPosition, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePanelPosition)
|
||||
window.removeEventListener('scroll', updatePanelPosition, true)
|
||||
}
|
||||
}, [open, usePortal, updatePanelPosition, query, browseTree, value.length])
|
||||
|
||||
const showTree = browseTree || !query.trim()
|
||||
|
||||
const panelContent = showTree ? (
|
||||
<SkillTreePickerPanel
|
||||
skills={skills}
|
||||
excludeIds={value}
|
||||
searchQuery={query}
|
||||
onPickSkill={(id) => addId(id)}
|
||||
pickMode="multi"
|
||||
/>
|
||||
) : (
|
||||
<ul className="multiselect-combo__list" role="listbox">
|
||||
{leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length === 0 ? (
|
||||
<li className="multiselect-combo__empty">{emptyHint}</li>
|
||||
) : (
|
||||
leaves
|
||||
.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase()))
|
||||
.map((l) => (
|
||||
<li key={normId(l.id)}>
|
||||
<button
|
||||
type="button"
|
||||
className="multiselect-combo__opt skill-tree__pick--path"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => addId(l.id)}
|
||||
>
|
||||
<span className="skill-tree__pick-name">{l.label}</span>
|
||||
<span className="skill-tree__pick-path">{l.pathLabel}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
|
||||
const dropdownPanel =
|
||||
open && (usePortal ? panelStyle : true) ? (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={
|
||||
'skill-tree-multiselect__panel' + (usePortal ? ' skill-tree-multiselect__panel--portal' : '')
|
||||
}
|
||||
style={usePortal ? panelStyle : undefined}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className={`multiselect-combo skill-tree-multiselect ${className}`.trim()} ref={rootRef}>
|
||||
<div className="multiselect-combo__chips">
|
||||
|
|
@ -85,7 +176,7 @@ export default function SkillTreeMultiSelect({
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="multiselect-combo__field">
|
||||
<div className="multiselect-combo__field" ref={fieldRef}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input multiselect-combo__input"
|
||||
|
|
@ -113,42 +204,8 @@ export default function SkillTreeMultiSelect({
|
|||
{browseLabel}
|
||||
</button>
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="skill-tree-multiselect__panel">
|
||||
{showTree ? (
|
||||
<SkillTreePickerPanel
|
||||
skills={skills}
|
||||
excludeIds={value}
|
||||
searchQuery={query}
|
||||
onPickSkill={(id) => addId(id)}
|
||||
pickMode="multi"
|
||||
/>
|
||||
) : (
|
||||
<ul className="multiselect-combo__list" role="listbox">
|
||||
{leaves.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase())).length ===
|
||||
0 ? (
|
||||
<li className="multiselect-combo__empty">{emptyHint}</li>
|
||||
) : (
|
||||
leaves
|
||||
.filter((l) => l.pathLabel.toLowerCase().includes(query.trim().toLowerCase()))
|
||||
.map((l) => (
|
||||
<li key={normId(l.id)}>
|
||||
<button
|
||||
type="button"
|
||||
className="multiselect-combo__opt skill-tree__pick--path"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => addId(l.id)}
|
||||
>
|
||||
<span className="skill-tree__pick-name">{l.label}</span>
|
||||
<span className="skill-tree__pick-path">{l.pathLabel}</span>
|
||||
</button>
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{!usePortal && dropdownPanel}
|
||||
{usePortal && dropdownPanel ? createPortal(dropdownPanel, document.body) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react'
|
||||
import NavStateLink from '../NavStateLink'
|
||||
import SkillProfileCompact from '../skills/SkillProfileCompact'
|
||||
import {
|
||||
frameworkSessionDurationLabel,
|
||||
splitFrameworkCommaAgg,
|
||||
|
|
@ -26,7 +27,16 @@ function CatalogGroup({ label, items, variant }) {
|
|||
/**
|
||||
* Einzelkarte für die Rahmenprogramm-Bibliothek.
|
||||
*/
|
||||
export default function FrameworkProgramListCard({ row, returnContext, onDelete }) {
|
||||
export default function FrameworkProgramListCard({
|
||||
row,
|
||||
returnContext,
|
||||
onDelete,
|
||||
skillSummary = null,
|
||||
skillSummaryLoading = false,
|
||||
skillFilterIds = [],
|
||||
skillDisplayLimit = 6,
|
||||
onShowSkillProfile,
|
||||
}) {
|
||||
const title = (row.title || '').trim() || `Rahmen #${row.id}`
|
||||
const description = (row.description || '').trim()
|
||||
const durationLabel = frameworkSessionDurationLabel(row)
|
||||
|
|
@ -112,6 +122,28 @@ export default function FrameworkProgramListCard({ row, returnContext, onDelete
|
|||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="fw-prog-card__section fw-prog-card__section--skills">
|
||||
<div className="fw-prog-card__section-head">
|
||||
<h3 className="fw-prog-card__section-title">Fähigkeiten</h3>
|
||||
{onShowSkillProfile ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-small fw-prog-card__skills-btn"
|
||||
onClick={() => onShowSkillProfile(row)}
|
||||
>
|
||||
Alle anzeigen
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<SkillProfileCompact
|
||||
summary={skillSummary}
|
||||
artifactType="framework_program"
|
||||
loading={skillSummaryLoading}
|
||||
displayLimit={skillDisplayLimit}
|
||||
highlightSkillIds={skillFilterIds}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<footer className="fw-prog-card__actions">
|
||||
<NavStateLink
|
||||
to={`/planning/framework-programs/${row.id}`}
|
||||
|
|
|
|||
|
|
@ -1,299 +1,179 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
collectDistinctSessionDurationsMinutes,
|
||||
EMPTY_FRAMEWORK_IMPORT_FILTERS,
|
||||
filterFrameworkPrograms,
|
||||
hasActiveFrameworkImportFilters,
|
||||
summarizeFrameworkImportFilters,
|
||||
} from '../../utils/frameworkProgramListHelpers'
|
||||
import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
|
||||
import { buildPlanningArtifactFilterChips } from '../../utils/planningArtifactFilterChips'
|
||||
import PlanningArtifactFilterModal from './PlanningArtifactFilterModal'
|
||||
|
||||
/**
|
||||
* Gemeinsamer Filter für Rahmenprogramm-Liste und Import-Dialog.
|
||||
* Filter-Leiste für Rahmenprogramm-Liste und Import-Dialog (UX wie Übungsliste).
|
||||
*/
|
||||
export default function FrameworkProgramsFilterBlock({
|
||||
programs = [],
|
||||
filters,
|
||||
onFiltersChange,
|
||||
panelOpen = true,
|
||||
onPanelOpenChange,
|
||||
catalogFocusAreas = [],
|
||||
catalogTrainingTypes = [],
|
||||
catalogTargetGroups = [],
|
||||
catalogSkills = [],
|
||||
skillSummaries = null,
|
||||
disabled = false,
|
||||
durationRadioName = 'fw-duration-mode',
|
||||
showHint = true,
|
||||
className = '',
|
||||
searchPlaceholder = 'Suche (Titel, Ziele, Katalog) …',
|
||||
filterModalTitle = 'Rahmenprogramme filtern',
|
||||
resultLabel = 'Rahmenprogramm',
|
||||
}) {
|
||||
const distinctDurations = useMemo(
|
||||
() => collectDistinctSessionDurationsMinutes(programs),
|
||||
[programs]
|
||||
)
|
||||
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
||||
|
||||
const matchCount = useMemo(
|
||||
() => filterFrameworkPrograms(programs, filters).length,
|
||||
[programs, filters]
|
||||
() => filterFrameworkPrograms(programs, filters, skillSummaries).length,
|
||||
[programs, filters, skillSummaries]
|
||||
)
|
||||
|
||||
const totalCount = (programs || []).length
|
||||
const filterActive = hasActiveFrameworkImportFilters(filters)
|
||||
const filterSummaryParts = useMemo(
|
||||
() =>
|
||||
summarizeFrameworkImportFilters(filters, {
|
||||
focusAreas: catalogFocusAreas,
|
||||
trainingTypes: catalogTrainingTypes,
|
||||
targetGroups: catalogTargetGroups,
|
||||
}),
|
||||
[filters, catalogFocusAreas, catalogTrainingTypes, catalogTargetGroups]
|
||||
)
|
||||
|
||||
const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
|
||||
const filterChips = useMemo(
|
||||
() =>
|
||||
buildPlanningArtifactFilterChips({
|
||||
filters,
|
||||
setFilters: onFiltersChange,
|
||||
catalogs: {
|
||||
focusAreas: catalogFocusAreas,
|
||||
trainingTypes: catalogTrainingTypes,
|
||||
targetGroups: catalogTargetGroups,
|
||||
skills: catalogSkills,
|
||||
},
|
||||
artifactType: 'framework_program',
|
||||
emptyFilters: EMPTY_FRAMEWORK_IMPORT_FILTERS,
|
||||
}),
|
||||
[
|
||||
filters,
|
||||
onFiltersChange,
|
||||
catalogFocusAreas,
|
||||
catalogTrainingTypes,
|
||||
catalogTargetGroups,
|
||||
catalogSkills,
|
||||
]
|
||||
)
|
||||
|
||||
const clearFilters = () => onFiltersChange({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
|
||||
|
||||
const toggleId = (key, id) => {
|
||||
const s = String(id)
|
||||
onFiltersChange((prev) => {
|
||||
const cur = prev[key] || []
|
||||
const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
|
||||
return { ...prev, [key]: next }
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!filterModalOpen) return undefined
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') setFilterModalOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [filterModalOpen])
|
||||
|
||||
const togglePanel = () => {
|
||||
if (onPanelOpenChange) onPanelOpenChange(!panelOpen)
|
||||
}
|
||||
const resultPlural = totalCount === 1 ? resultLabel : `${resultLabel}e`
|
||||
|
||||
return (
|
||||
<div className={'fw-prog-filter-block' + (className ? ` ${className}` : '')}>
|
||||
<div className={'fw-prog-filter-block planning-list-filter-bar' + (className ? ` ${className}` : '')}>
|
||||
<div className="card exercise-search-bar planning-list-filter-bar__search">
|
||||
<label className="form-label">Suche</label>
|
||||
<input
|
||||
type="search"
|
||||
className="form-input exercise-search-bar__primary"
|
||||
value={filters.query}
|
||||
onChange={(e) => onFiltersChange((prev) => ({ ...prev, query: e.target.value }))}
|
||||
placeholder={searchPlaceholder}
|
||||
disabled={disabled}
|
||||
enterKeyHint="search"
|
||||
/>
|
||||
<div className="exercise-search-bar__actions exercise-search-bar__actions--split">
|
||||
<div className="exercise-search-bar__actions-main">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary exercise-filter-trigger"
|
||||
disabled={disabled}
|
||||
onClick={() => setFilterModalOpen(true)}
|
||||
>
|
||||
Filter
|
||||
{filterChips.length > 0 ? (
|
||||
<span className="exercise-filter-badge" aria-hidden>
|
||||
{filterChips.length}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{filterChips.length > 0 ? (
|
||||
<button type="button" className="btn" disabled={disabled} onClick={clearFilters}>
|
||||
Alle entfernen
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{filterChips.length > 0 ? (
|
||||
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
|
||||
{filterChips.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className="exercise-filter-chip"
|
||||
title="Filter entfernen"
|
||||
disabled={disabled}
|
||||
onClick={() => c.onRemove()}
|
||||
>
|
||||
<span className="exercise-filter-chip__text">{c.label}</span>
|
||||
<span className="exercise-filter-chip__x" aria-hidden>
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{showHint ? (
|
||||
<p className="exercise-search-hint form-sub" style={{ marginBottom: 0 }}>
|
||||
Fachliche Filter über „Filter“ — zwischen Feldern UND. Fähigkeiten vergleichen nur unter
|
||||
Rahmenprogrammen.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="fw-import-results-bar">
|
||||
<div className="fw-import-results-bar__count">
|
||||
<strong className="fw-import-results-bar__num">{matchCount}</strong>
|
||||
<span>
|
||||
{' '}
|
||||
von {totalCount} Rahmenprogramm{totalCount === 1 ? '' : 'en'}
|
||||
von {totalCount} {resultPlural}
|
||||
</span>
|
||||
{matchCount === 0 && totalCount > 0 ? (
|
||||
<span className="fw-import-results-bar__warn"> — kein Treffer</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="fw-import-results-bar__actions">
|
||||
{filterActive ? (
|
||||
<span className="fw-import-filter-badge" title={filterSummaryParts.join(' · ')}>
|
||||
Filter aktiv
|
||||
</span>
|
||||
) : null}
|
||||
{filterActive ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
disabled={disabled}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
) : null}
|
||||
{onPanelOpenChange ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
|
||||
disabled={disabled}
|
||||
onClick={togglePanel}
|
||||
aria-expanded={panelOpen}
|
||||
>
|
||||
{panelOpen ? 'Filter einklappen' : 'Filter anzeigen'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{filterActive ? (
|
||||
<div className="fw-import-results-bar__actions">
|
||||
<span className="fw-import-filter-badge">Filter aktiv</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!panelOpen && filterActive && filterSummaryParts.length > 0 ? (
|
||||
<ul className="fw-import-filter-chips" aria-label="Aktive Filter">
|
||||
{filterSummaryParts.map((part) => (
|
||||
<li key={part} className="fw-import-filter-chip">
|
||||
{part}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{panelOpen ? (
|
||||
<div className="fw-import-filter-panel">
|
||||
<div className="fw-import-filter-panel__grid">
|
||||
<div className="form-row fw-import-filter-panel__search">
|
||||
<label className="form-label">Suche (Titel, Ziele, Katalog)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={filters.query}
|
||||
onChange={(e) => updateFilter({ query: e.target.value })}
|
||||
placeholder="z. B. Gürtel, Koordination …"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset className="fw-import-duration-fieldset">
|
||||
<legend className="form-label">Ziel-Session-Dauer</legend>
|
||||
<div className="fw-import-duration-mode" role="radiogroup" aria-label="Dauer-Filtermodus">
|
||||
{[
|
||||
{ id: 'any', label: 'Alle' },
|
||||
{ id: 'range', label: 'Zeitspanne' },
|
||||
{ id: 'preset', label: 'Vorhandene Zeiten' },
|
||||
].map((opt) => (
|
||||
<label key={opt.id} className="fw-import-duration-mode__opt">
|
||||
<input
|
||||
type="radio"
|
||||
name={durationRadioName}
|
||||
checked={filters.durationMode === opt.id}
|
||||
disabled={disabled || (opt.id === 'preset' && distinctDurations.length === 0)}
|
||||
onChange={() =>
|
||||
updateFilter({
|
||||
durationMode: opt.id,
|
||||
...(opt.id === 'any'
|
||||
? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filters.durationMode === 'range' ? (
|
||||
<div className="responsive-grid-2 fw-import-duration-range">
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Von (Minuten)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="form-input"
|
||||
value={filters.durationRangeFrom}
|
||||
onChange={(e) =>
|
||||
updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value })
|
||||
}
|
||||
placeholder="z. B. 60"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Bis (Minuten)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="form-input"
|
||||
value={filters.durationRangeTo}
|
||||
onChange={(e) =>
|
||||
updateFilter({ durationMode: 'range', durationRangeTo: e.target.value })
|
||||
}
|
||||
placeholder="z. B. 90"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filters.durationMode === 'preset' ? (
|
||||
distinctDurations.length === 0 ? (
|
||||
<p className="form-sub" style={{ margin: '8px 0 0' }}>
|
||||
In der Bibliothek sind noch keine Session-Dauern hinterlegt. Nutze „Zeitspanne“ oder lege
|
||||
Dauer pro Session im Rahmenprogramm fest.
|
||||
</p>
|
||||
) : (
|
||||
<div className="fw-import-duration-presets">
|
||||
{distinctDurations.map((min) => {
|
||||
const on = filters.durationPresetMin === min
|
||||
return (
|
||||
<button
|
||||
key={min}
|
||||
type="button"
|
||||
className={
|
||||
'btn framework-ctrl framework-ctrl--xs' +
|
||||
(on ? ' fw-import-duration-preset--on' : ' btn-secondary')
|
||||
}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
updateFilter({
|
||||
durationMode: 'preset',
|
||||
durationPresetMin: on ? null : min,
|
||||
durationRangeFrom: '',
|
||||
durationRangeTo: '',
|
||||
})
|
||||
}
|
||||
>
|
||||
{formatDurationDisplay(min)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</fieldset>
|
||||
|
||||
{catalogFocusAreas.length > 0 ? (
|
||||
<div className="fw-import-catalog-block">
|
||||
<span className="form-label">Fokusbereich</span>
|
||||
<div className="framework-catalog-checkgrid">
|
||||
{catalogFocusAreas.map((fa) => (
|
||||
<label key={fa.id} className="framework-catalog-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filters.focusAreaIds || []).includes(String(fa.id))}
|
||||
onChange={() => toggleId('focusAreaIds', fa.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{fa.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{catalogTrainingTypes.length > 0 ? (
|
||||
<div className="fw-import-catalog-block">
|
||||
<span className="form-label">Trainingsart</span>
|
||||
<div className="framework-catalog-checkgrid">
|
||||
{catalogTrainingTypes.map((t) => (
|
||||
<label key={t.id} className="framework-catalog-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filters.trainingTypeIds || []).includes(String(t.id))}
|
||||
onChange={() => toggleId('trainingTypeIds', t.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{t.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{catalogTargetGroups.length > 0 ? (
|
||||
<div className="fw-import-catalog-block">
|
||||
<span className="form-label">Zielgruppe</span>
|
||||
<div className="framework-catalog-checkgrid">
|
||||
{catalogTargetGroups.map((tg) => (
|
||||
<label key={tg.id} className="framework-catalog-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filters.targetGroupIds || []).includes(String(tg.id))}
|
||||
onChange={() => toggleId('targetGroupIds', tg.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{tg.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showHint ? (
|
||||
<p className="form-sub fw-import-filter-panel__hint">
|
||||
Entwicklungsziele sind freie Texte — die Suche durchsucht auch Ziel-Titel. Bei der Dauer werden nur
|
||||
Programme mit hinterlegter Session-Dauer berücksichtigt.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<PlanningArtifactFilterModal
|
||||
open={filterModalOpen}
|
||||
onClose={() => setFilterModalOpen(false)}
|
||||
filters={filters}
|
||||
onFiltersChange={onFiltersChange}
|
||||
artifactType="framework_program"
|
||||
programs={programs}
|
||||
catalogFocusAreas={catalogFocusAreas}
|
||||
catalogTrainingTypes={catalogTrainingTypes}
|
||||
catalogTargetGroups={catalogTargetGroups}
|
||||
catalogSkills={catalogSkills}
|
||||
durationRadioName={durationRadioName}
|
||||
onResetAll={clearFilters}
|
||||
disabled={disabled}
|
||||
title={filterModalTitle}
|
||||
showCatalogFilters
|
||||
showDurationFilters
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
275
frontend/src/components/planning/PlanningArtifactFilterModal.jsx
Normal file
275
frontend/src/components/planning/PlanningArtifactFilterModal.jsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import React, { useMemo } from 'react'
|
||||
import { collectDistinctSessionDurationsMinutes } from '../../utils/frameworkProgramListHelpers'
|
||||
import { formatDurationDisplay } from '../../utils/trainingDurationUtils'
|
||||
import PlanningSkillFilterSection from './PlanningSkillFilterSection'
|
||||
|
||||
/**
|
||||
* Filter-Modal für Rahmenprogramme / Trainingsmodule (UX wie ExerciseListFilterModal).
|
||||
*/
|
||||
export default function PlanningArtifactFilterModal({
|
||||
open,
|
||||
onClose,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
artifactType = 'framework_program',
|
||||
programs = [],
|
||||
catalogFocusAreas = [],
|
||||
catalogTrainingTypes = [],
|
||||
catalogTargetGroups = [],
|
||||
catalogSkills = [],
|
||||
durationRadioName = 'planning-duration-mode',
|
||||
onResetAll,
|
||||
disabled = false,
|
||||
showCatalogFilters = true,
|
||||
showDurationFilters = true,
|
||||
title = 'Filtern',
|
||||
}) {
|
||||
const distinctDurations = useMemo(
|
||||
() => (showDurationFilters ? collectDistinctSessionDurationsMinutes(programs) : []),
|
||||
[programs, showDurationFilters]
|
||||
)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const updateFilter = (patch) => onFiltersChange((prev) => ({ ...prev, ...patch }))
|
||||
|
||||
const toggleId = (key, id) => {
|
||||
const s = String(id)
|
||||
onFiltersChange((prev) => {
|
||||
const cur = prev[key] || []
|
||||
const next = cur.includes(s) ? cur.filter((x) => x !== s) : [...cur, s]
|
||||
return { ...prev, [key]: next }
|
||||
})
|
||||
}
|
||||
|
||||
const artifactLabel =
|
||||
artifactType === 'training_module'
|
||||
? 'Trainingsmodule'
|
||||
: artifactType === 'framework_program'
|
||||
? 'Rahmenprogramme'
|
||||
: 'Einträge'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="admin-modal-backdrop"
|
||||
role="presentation"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="admin-modal-sheet exercise-filter-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="planning-filter-modal-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="admin-modal-sheet__header">
|
||||
<h3 id="planning-filter-modal-title" className="admin-modal-sheet__title">
|
||||
{title}
|
||||
</h3>
|
||||
<button type="button" className="btn btn-secondary admin-modal-sheet__close" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
|
||||
<p className="muted" style={{ marginTop: 0, marginBottom: '14px' }}>
|
||||
Zwischen den Bereichen gilt <strong>UND</strong>. Mehrere Katalog-Werte innerhalb eines Feldes
|
||||
bedeuten <strong>ODER</strong>. Fähigkeiten filtern nach Trainingsgewicht — nur unter sichtbaren{' '}
|
||||
{artifactLabel}.
|
||||
</p>
|
||||
|
||||
{showCatalogFilters ? (
|
||||
<section className="exercise-filter-section">
|
||||
<h4 className="exercise-filter-section-title">Katalog</h4>
|
||||
<div className="exercise-filters-modal-grid exercise-filters-modal-grid--catalog">
|
||||
{catalogFocusAreas.length > 0 ? (
|
||||
<div className="fw-import-catalog-block">
|
||||
<span className="form-label">Fokusbereich</span>
|
||||
<div className="framework-catalog-checkgrid">
|
||||
{catalogFocusAreas.map((fa) => (
|
||||
<label key={fa.id} className="framework-catalog-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filters.focusAreaIds || []).includes(String(fa.id))}
|
||||
onChange={() => toggleId('focusAreaIds', fa.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{fa.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{catalogTrainingTypes.length > 0 ? (
|
||||
<div className="fw-import-catalog-block">
|
||||
<span className="form-label">Trainingsart</span>
|
||||
<div className="framework-catalog-checkgrid">
|
||||
{catalogTrainingTypes.map((t) => (
|
||||
<label key={t.id} className="framework-catalog-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filters.trainingTypeIds || []).includes(String(t.id))}
|
||||
onChange={() => toggleId('trainingTypeIds', t.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{t.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{catalogTargetGroups.length > 0 ? (
|
||||
<div className="fw-import-catalog-block">
|
||||
<span className="form-label">Zielgruppe</span>
|
||||
<div className="framework-catalog-checkgrid">
|
||||
{catalogTargetGroups.map((tg) => (
|
||||
<label key={tg.id} className="framework-catalog-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(filters.targetGroupIds || []).includes(String(tg.id))}
|
||||
onChange={() => toggleId('targetGroupIds', tg.id)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span>{tg.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{showDurationFilters ? (
|
||||
<section className="exercise-filter-section">
|
||||
<h4 className="exercise-filter-section-title">Session-Dauer</h4>
|
||||
<fieldset className="fw-import-duration-fieldset" style={{ border: 0, padding: 0, margin: 0 }}>
|
||||
<div className="fw-import-duration-mode" role="radiogroup" aria-label="Dauer-Filtermodus">
|
||||
{[
|
||||
{ id: 'any', label: 'Alle' },
|
||||
{ id: 'range', label: 'Zeitspanne' },
|
||||
{ id: 'preset', label: 'Vorhandene Zeiten' },
|
||||
].map((opt) => (
|
||||
<label key={opt.id} className="fw-import-duration-mode__opt">
|
||||
<input
|
||||
type="radio"
|
||||
name={durationRadioName}
|
||||
checked={filters.durationMode === opt.id}
|
||||
disabled={disabled || (opt.id === 'preset' && distinctDurations.length === 0)}
|
||||
onChange={() =>
|
||||
updateFilter({
|
||||
durationMode: opt.id,
|
||||
...(opt.id === 'any'
|
||||
? { durationRangeFrom: '', durationRangeTo: '', durationPresetMin: null }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filters.durationMode === 'range' ? (
|
||||
<div className="responsive-grid-2 fw-import-duration-range" style={{ marginTop: 10 }}>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Von (Minuten)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="form-input"
|
||||
value={filters.durationRangeFrom}
|
||||
onChange={(e) =>
|
||||
updateFilter({ durationMode: 'range', durationRangeFrom: e.target.value })
|
||||
}
|
||||
placeholder="z. B. 60"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Bis (Minuten)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="form-input"
|
||||
value={filters.durationRangeTo}
|
||||
onChange={(e) =>
|
||||
updateFilter({ durationMode: 'range', durationRangeTo: e.target.value })
|
||||
}
|
||||
placeholder="z. B. 90"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{filters.durationMode === 'preset' ? (
|
||||
distinctDurations.length === 0 ? (
|
||||
<p className="form-sub" style={{ margin: '8px 0 0' }}>
|
||||
Noch keine Session-Dauern hinterlegt.
|
||||
</p>
|
||||
) : (
|
||||
<div className="fw-import-duration-presets" style={{ marginTop: 10 }}>
|
||||
{distinctDurations.map((min) => {
|
||||
const on = filters.durationPresetMin === min
|
||||
return (
|
||||
<button
|
||||
key={min}
|
||||
type="button"
|
||||
className={
|
||||
'btn framework-ctrl framework-ctrl--xs' +
|
||||
(on ? ' fw-import-duration-preset--on' : ' btn-secondary')
|
||||
}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
updateFilter({
|
||||
durationMode: 'preset',
|
||||
durationPresetMin: on ? null : min,
|
||||
durationRangeFrom: '',
|
||||
durationRangeTo: '',
|
||||
})
|
||||
}
|
||||
>
|
||||
{formatDurationDisplay(min)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</fieldset>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{catalogSkills.length > 0 ? (
|
||||
<section className="exercise-filter-section exercise-filter-section--last">
|
||||
<h4 className="exercise-filter-section-title">Fähigkeit und Trainingsgewicht</h4>
|
||||
<PlanningSkillFilterSection
|
||||
artifactType={artifactType}
|
||||
skillIds={filters.skillIds || []}
|
||||
onSkillIdsChange={(v) => updateFilter({ skillIds: v })}
|
||||
skillMinClubPercent={filters.skillMinClubPercent ?? 0}
|
||||
onSkillMinClubPercentChange={(v) => updateFilter({ skillMinClubPercent: v })}
|
||||
skillSort={filters.skillSort || 'title'}
|
||||
onSkillSortChange={(v) => updateFilter({ skillSort: v })}
|
||||
skillsCatalog={catalogSkills}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="exercise-filter-modal__footer">
|
||||
<button type="button" className="btn" onClick={onResetAll} disabled={disabled}>
|
||||
Alle Filter zurücksetzen
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={onClose}>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import React from 'react'
|
||||
import SkillTreeMultiSelect from '../SkillTreeMultiSelect'
|
||||
import { peerCorpusCountLabel } from '../../utils/skillProfileListHelpers'
|
||||
|
||||
/**
|
||||
* Fähigkeiten-Filter für Planungsartefakte (Rahmenprogramme, Module).
|
||||
* Semantik wie Übungsliste (SkillTreeMultiSelect), aber mit Peer-Prozent statt Stufen.
|
||||
*/
|
||||
export default function PlanningSkillFilterSection({
|
||||
artifactType = 'framework_program',
|
||||
skillIds = [],
|
||||
onSkillIdsChange,
|
||||
skillMinClubPercent = 0,
|
||||
onSkillMinClubPercentChange,
|
||||
skillSort = 'title',
|
||||
onSkillSortChange,
|
||||
skillsCatalog = [],
|
||||
disabled = false,
|
||||
}) {
|
||||
const peerLabel = peerCorpusCountLabel(artifactType)
|
||||
const peerMaxLabel =
|
||||
artifactType === 'framework_program' ? 'Rahmenprogramm-Maximum' : `${peerLabel}-Maximum`
|
||||
|
||||
return (
|
||||
<div className="exercise-filter-skill-block">
|
||||
<label className="form-label">Fähigkeit</label>
|
||||
<SkillTreeMultiSelect
|
||||
value={skillIds}
|
||||
onChange={onSkillIdsChange}
|
||||
skills={skillsCatalog}
|
||||
placeholder="Fähigkeit suchen …"
|
||||
/>
|
||||
<p className="exercise-filter-skill-hint">
|
||||
Trainingsgewicht relativ zum stärksten sichtbaren Eintrag unter {peerLabel} — nicht gemischt mit
|
||||
anderen Planungs-Artefakttypen.
|
||||
</p>
|
||||
{(skillIds || []).length > 0 ? (
|
||||
<div className="fw-import-skill-options" style={{ marginTop: 10 }}>
|
||||
<div className="form-row" style={{ marginBottom: 8 }}>
|
||||
<label className="form-label">Mindest-Anteil am {peerMaxLabel}</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={String(skillMinClubPercent ?? 0)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onSkillMinClubPercentChange(Number(e.target.value) || 0)}
|
||||
>
|
||||
<option value="0">Kein Minimum (nur markieren)</option>
|
||||
<option value="25">mind. 25%</option>
|
||||
<option value="50">mind. 50%</option>
|
||||
<option value="75">mind. 75%</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row" style={{ marginBottom: 0 }}>
|
||||
<label className="form-label">Sortierung</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={skillSort || 'title'}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onSkillSortChange(e.target.value)}
|
||||
>
|
||||
<option value="title">Bibliotheks-Reihenfolge</option>
|
||||
<option value="skill_strength">Stärkste gewählte Fähigkeit zuerst</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
frontend/src/components/planning/TrainingModulesFilterBlock.jsx
Normal file
148
frontend/src/components/planning/TrainingModulesFilterBlock.jsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
EMPTY_TRAINING_MODULE_FILTERS,
|
||||
filterTrainingModules,
|
||||
hasActiveTrainingModuleFilters,
|
||||
} from '../../utils/trainingModuleListHelpers'
|
||||
import { buildPlanningArtifactFilterChips } from '../../utils/planningArtifactFilterChips'
|
||||
import PlanningArtifactFilterModal from './PlanningArtifactFilterModal'
|
||||
|
||||
/**
|
||||
* Filter-Leiste für Trainingsmodule (UX wie Übungsliste / Rahmenprogramme).
|
||||
*/
|
||||
export default function TrainingModulesFilterBlock({
|
||||
modules = [],
|
||||
filters,
|
||||
onFiltersChange,
|
||||
catalogSkills = [],
|
||||
skillSummaries = null,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}) {
|
||||
const [filterModalOpen, setFilterModalOpen] = useState(false)
|
||||
|
||||
const matchCount = useMemo(
|
||||
() => filterTrainingModules(modules, filters, skillSummaries).length,
|
||||
[modules, filters, skillSummaries]
|
||||
)
|
||||
|
||||
const totalCount = (modules || []).length
|
||||
const filterActive = hasActiveTrainingModuleFilters(filters)
|
||||
|
||||
const filterChips = useMemo(
|
||||
() =>
|
||||
buildPlanningArtifactFilterChips({
|
||||
filters,
|
||||
setFilters: onFiltersChange,
|
||||
catalogs: { skills: catalogSkills },
|
||||
artifactType: 'training_module',
|
||||
emptyFilters: EMPTY_TRAINING_MODULE_FILTERS,
|
||||
}),
|
||||
[filters, onFiltersChange, catalogSkills]
|
||||
)
|
||||
|
||||
const clearFilters = () => onFiltersChange({ ...EMPTY_TRAINING_MODULE_FILTERS })
|
||||
|
||||
useEffect(() => {
|
||||
if (!filterModalOpen) return undefined
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') setFilterModalOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => document.removeEventListener('keydown', onKey)
|
||||
}, [filterModalOpen])
|
||||
|
||||
return (
|
||||
<div className={'planning-list-filter-bar' + (className ? ` ${className}` : '')}>
|
||||
<div className="card exercise-search-bar planning-list-filter-bar__search">
|
||||
<label className="form-label">Suche</label>
|
||||
<input
|
||||
type="search"
|
||||
className="form-input exercise-search-bar__primary"
|
||||
value={filters.query}
|
||||
onChange={(e) => onFiltersChange((prev) => ({ ...prev, query: e.target.value }))}
|
||||
placeholder="Titel oder Kurzbeschreibung …"
|
||||
disabled={disabled}
|
||||
enterKeyHint="search"
|
||||
/>
|
||||
<div className="exercise-search-bar__actions exercise-search-bar__actions--split">
|
||||
<div className="exercise-search-bar__actions-main">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary exercise-filter-trigger"
|
||||
disabled={disabled}
|
||||
onClick={() => setFilterModalOpen(true)}
|
||||
>
|
||||
Filter
|
||||
{filterChips.length > 0 ? (
|
||||
<span className="exercise-filter-badge" aria-hidden>
|
||||
{filterChips.length}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{filterChips.length > 0 ? (
|
||||
<button type="button" className="btn" disabled={disabled} onClick={clearFilters}>
|
||||
Alle entfernen
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{filterChips.length > 0 ? (
|
||||
<div className="exercise-filter-chips-row" role="list" aria-label="Aktive Filter">
|
||||
{filterChips.map((c) => (
|
||||
<button
|
||||
key={c.key}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className="exercise-filter-chip"
|
||||
title="Filter entfernen"
|
||||
disabled={disabled}
|
||||
onClick={() => c.onRemove()}
|
||||
>
|
||||
<span className="exercise-filter-chip__text">{c.label}</span>
|
||||
<span className="exercise-filter-chip__x" aria-hidden>
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="exercise-search-hint form-sub" style={{ marginBottom: 0 }}>
|
||||
Fachliche Filter über „Filter“. Fähigkeiten vergleichen nur unter sichtbaren Modulen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="fw-import-results-bar">
|
||||
<div className="fw-import-results-bar__count">
|
||||
<strong className="fw-import-results-bar__num">{matchCount}</strong>
|
||||
<span>
|
||||
{' '}
|
||||
von {totalCount} Modul{totalCount === 1 ? '' : 'en'}
|
||||
</span>
|
||||
{matchCount === 0 && totalCount > 0 ? (
|
||||
<span className="fw-import-results-bar__warn"> — kein Treffer</span>
|
||||
) : null}
|
||||
</div>
|
||||
{filterActive ? (
|
||||
<div className="fw-import-results-bar__actions">
|
||||
<span className="fw-import-filter-badge">Filter aktiv</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<PlanningArtifactFilterModal
|
||||
open={filterModalOpen}
|
||||
onClose={() => setFilterModalOpen(false)}
|
||||
filters={filters}
|
||||
onFiltersChange={onFiltersChange}
|
||||
artifactType="training_module"
|
||||
catalogSkills={catalogSkills}
|
||||
onResetAll={clearFilters}
|
||||
disabled={disabled}
|
||||
title="Trainingsmodule filtern"
|
||||
showCatalogFilters={false}
|
||||
showDurationFilters={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -35,12 +35,10 @@ export default function TrainingPlanningFrameworkImportModal({
|
|||
onClose,
|
||||
}) {
|
||||
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
|
||||
const [filterPanelOpen, setFilterPanelOpen] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setFilters({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS })
|
||||
setFilterPanelOpen(true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
|
|
@ -80,13 +78,14 @@ export default function TrainingPlanningFrameworkImportModal({
|
|||
programs={frameworkProgramsList}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
panelOpen={filterPanelOpen}
|
||||
onPanelOpenChange={setFilterPanelOpen}
|
||||
catalogFocusAreas={catalogFocusAreas}
|
||||
catalogTrainingTypes={catalogTrainingTypes}
|
||||
catalogTargetGroups={catalogTargetGroups}
|
||||
disabled={fwImportSubmitting}
|
||||
durationRadioName="fw-duration-mode"
|
||||
showHint={false}
|
||||
searchPlaceholder="Rahmenprogramm suchen …"
|
||||
filterModalTitle="Rahmenprogramme filtern"
|
||||
/>
|
||||
|
||||
<div className="form-row fw-import-program-select">
|
||||
|
|
|
|||
185
frontend/src/components/skills/SkillDiscoveryPanel.jsx
Normal file
185
frontend/src/components/skills/SkillDiscoveryPanel.jsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import React, { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../../utils/api'
|
||||
|
||||
const ARTIFACT_LABELS = {
|
||||
framework_program: 'Rahmenprogramm',
|
||||
training_module: 'Trainingsmodul',
|
||||
progression_graph: 'Regressionspfad',
|
||||
}
|
||||
|
||||
function formatScore(value) {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return '0'
|
||||
return n % 1 === 0 ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorschläge für Planungsartefakte anhand gewählter Fähigkeiten (Phase 3).
|
||||
*/
|
||||
export default function SkillDiscoveryPanel({ skills = [] }) {
|
||||
const [selectedIds, setSelectedIds] = useState([])
|
||||
const [query, setQuery] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [result, setResult] = useState(null)
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
const list = (skills || []).filter((s) => s.status !== 'inactive')
|
||||
if (!q) return list
|
||||
return list.filter(
|
||||
(s) =>
|
||||
(s.name || '').toLowerCase().includes(q) ||
|
||||
(s.category || '').toLowerCase().includes(q)
|
||||
)
|
||||
}, [skills, query])
|
||||
|
||||
const toggleSkill = (id) => {
|
||||
const s = String(id)
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]
|
||||
)
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
const ids = selectedIds.map((x) => parseInt(x, 10)).filter((n) => n > 0)
|
||||
if (!ids.length) {
|
||||
setError('Wähle mindestens eine Fähigkeit.')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setResult(null)
|
||||
try {
|
||||
const data = await api.getSkillDiscoverySuggestions(ids, { limit: 25 })
|
||||
setResult(data)
|
||||
} catch (e) {
|
||||
setError(e.message || 'Suche fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="skill-discovery card">
|
||||
<h2 className="skill-discovery__title">Planungs-Vorschläge</h2>
|
||||
<p className="form-sub skill-discovery__lead">
|
||||
Wähle Fähigkeiten, die du schwerpunktmäßig entwickeln willst — Shinkan schlägt passende
|
||||
Rahmenprogramme, Trainingsmodule und Regressionspfade vor. Sortierung nach absolutem
|
||||
Trainingsgewicht (nicht nach Anteil innerhalb des Plans).
|
||||
</p>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Fähigkeiten filtern</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Name oder Kategorie …"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="skill-discovery__pick-grid" role="group" aria-label="Fähigkeiten auswählen">
|
||||
{filteredSkills.length === 0 ? (
|
||||
<p className="form-sub">Keine Fähigkeiten gefunden.</p>
|
||||
) : (
|
||||
filteredSkills.map((sk) => (
|
||||
<label key={sk.id} className="skill-discovery__pick">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(String(sk.id))}
|
||||
onChange={() => toggleSkill(sk.id)}
|
||||
/>
|
||||
<span className="skill-discovery__pick-name">{sk.name}</span>
|
||||
{sk.category ? (
|
||||
<span className="skill-discovery__pick-cat">{sk.category}</span>
|
||||
) : null}
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="skill-discovery__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={loading || selectedIds.length === 0}
|
||||
onClick={handleSearch}
|
||||
>
|
||||
{loading ? 'Suche …' : 'Bibliothek durchsuchen'}
|
||||
</button>
|
||||
{selectedIds.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => {
|
||||
setSelectedIds([])
|
||||
setResult(null)
|
||||
setError('')
|
||||
}}
|
||||
>
|
||||
Auswahl leeren
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="skill-discovery__error">{error}</p> : null}
|
||||
|
||||
{result?.suggestions?.length > 0 ? (
|
||||
<ul className="skill-discovery__results">
|
||||
{result.suggestions.map((item) => {
|
||||
const matchScore = item.match?.match_score ?? item.match?.match_weight ?? 0
|
||||
const focusPct = item.match?.artifact_focus_percent ?? item.match?.match_percent
|
||||
const topByCat = item.skill_profile_summary?.top_by_category || []
|
||||
return (
|
||||
<li key={`${item.artifact_type}-${item.artifact_id}`} className="skill-discovery__result card">
|
||||
<div className="skill-discovery__result-head">
|
||||
<span className="skill-discovery__result-type">
|
||||
{ARTIFACT_LABELS[item.artifact_type] || item.artifact_type}
|
||||
</span>
|
||||
<span className="skill-discovery__result-match" title="Summe der Trainingsgewichte der gewählten Fähigkeiten">
|
||||
Gewicht {formatScore(matchScore)}
|
||||
</span>
|
||||
</div>
|
||||
<strong className="skill-discovery__result-title">
|
||||
{item.artifact_title || `#${item.artifact_id}`}
|
||||
</strong>
|
||||
{item.match?.matched_skills?.length > 0 ? (
|
||||
<p className="skill-discovery__result-skills">
|
||||
{item.match.matched_skills.map((m) => m.skill_name).join(' · ')}
|
||||
{focusPct != null ? ` (${formatScore(focusPct)}% des Plans)` : null}
|
||||
</p>
|
||||
) : null}
|
||||
{topByCat.length > 0 ? (
|
||||
<ul className="skill-discovery__result-cats">
|
||||
{topByCat.slice(0, 4).map((row) => (
|
||||
<li key={`${row.category_name}-${row.skill_id}`}>
|
||||
<span className="skill-discovery__result-cat-name">{row.category_name}</span>
|
||||
<span>{row.skill_name}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{item.path ? (
|
||||
<Link to={item.path} className="btn btn-secondary btn-small skill-discovery__result-link">
|
||||
Öffnen
|
||||
</Link>
|
||||
) : item.artifact_type === 'progression_graph' ? (
|
||||
<p className="form-sub" style={{ margin: '8px 0 0' }}>
|
||||
Regressionspfad in der Übungsliste unter „Progressionsgraph“ bearbeiten.
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
) : result && !loading ? (
|
||||
<p className="form-sub skill-discovery__no-hit">
|
||||
Keine passenden Artefakte in deiner sichtbaren Bibliothek — prüfe Fähigkeiten-Verknüpfungen an
|
||||
den Übungen oder erweitere die Auswahl.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/skills/SkillProfileCompact.jsx
Normal file
63
frontend/src/components/skills/SkillProfileCompact.jsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react'
|
||||
import {
|
||||
formatClubPercent,
|
||||
formatSkillWeight,
|
||||
kpiRowsFromSummary,
|
||||
peerPercentSuffix,
|
||||
} from '../../utils/skillProfileListHelpers'
|
||||
|
||||
/**
|
||||
* Kleine KPI-Kacheln: je Unterkategorie die Top-Fähigkeit (Listen/Karten).
|
||||
*/
|
||||
export default function SkillProfileCompact({
|
||||
summary,
|
||||
artifactType = 'training_module',
|
||||
loading = false,
|
||||
emptyText = 'Keine Fähigkeiten',
|
||||
displayLimit = 24,
|
||||
highlightSkillIds = [],
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="skill-kpi-grid skill-kpi-grid--loading" aria-busy="true">
|
||||
<span className="form-sub">…</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rows = kpiRowsFromSummary(summary, { limit: displayLimit })
|
||||
const highlight = new Set((highlightSkillIds || []).map(String))
|
||||
const peerLabel = peerPercentSuffix(artifactType)
|
||||
|
||||
if (!rows.length) {
|
||||
return summary ? <p className="form-sub skill-kpi-grid--empty">{emptyText}</p> : null
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="skill-kpi-grid" aria-label="Fähigkeiten je Kategorie">
|
||||
{rows.map((row) => {
|
||||
const highlighted = highlight.has(String(row.skill_id))
|
||||
const isBest = row.is_club_best_for_skill || row.universal_percent >= 100
|
||||
return (
|
||||
<li
|
||||
key={`${row.category_name}-${row.skill_id}`}
|
||||
className={
|
||||
'skill-kpi-tile' +
|
||||
(highlighted ? ' skill-kpi-tile--highlight' : '') +
|
||||
(isBest ? ' skill-kpi-tile--best' : '')
|
||||
}
|
||||
title={`${row.category_name}: ${row.skill_name} — Gewicht ${formatSkillWeight(row.weight ?? row.score)}, ${formatClubPercent(row.universal_percent)} unter ${peerLabel}`}
|
||||
>
|
||||
<span className="skill-kpi-tile__cat">{row.category_name}</span>
|
||||
<span className="skill-kpi-tile__name">{row.skill_name}</span>
|
||||
<span className="skill-kpi-tile__score">{formatSkillWeight(row.weight ?? row.score)}</span>
|
||||
<span className="skill-kpi-tile__pct">
|
||||
{isBest ? '★ ' : ''}
|
||||
{formatClubPercent(row.universal_percent)} {peerLabel}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
85
frontend/src/components/skills/SkillProfileFullModal.jsx
Normal file
85
frontend/src/components/skills/SkillProfileFullModal.jsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import api from '../../utils/api'
|
||||
import { peerCorpusCountLabel } from '../../utils/skillProfileListHelpers'
|
||||
import SkillProfilePanel from './SkillProfilePanel'
|
||||
|
||||
/**
|
||||
* Vollständiges Fähigkeiten-Profil in einem Modal (Listen-Kontext).
|
||||
*/
|
||||
export default function SkillProfileFullModal({
|
||||
open,
|
||||
onClose,
|
||||
artifactType = 'framework_program',
|
||||
artifactId,
|
||||
title = 'Fähigkeiten-Profil',
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [data, setData] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !artifactId) return undefined
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError('')
|
||||
setData(null)
|
||||
const load =
|
||||
artifactType === 'training_module'
|
||||
? api.getTrainingModuleSkillProfile(artifactId)
|
||||
: artifactType === 'progression_graph'
|
||||
? api.getProgressionGraphSkillProfile(artifactId)
|
||||
: api.getFrameworkProgramSkillProfile(artifactId)
|
||||
load
|
||||
.then((res) => {
|
||||
if (!cancelled) setData(res)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(e.message || 'Profil konnte nicht geladen werden')
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, artifactId, artifactType])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const peerCountLabel = peerCorpusCountLabel(artifactType)
|
||||
const scale = data?.reference_scale
|
||||
|
||||
return (
|
||||
<div className="skill-profile-modal" role="dialog" aria-modal="true" aria-labelledby="skill-profile-modal-title">
|
||||
<button type="button" className="skill-profile-modal__backdrop" aria-label="Schließen" onClick={onClose} />
|
||||
<div className="skill-profile-modal__panel card">
|
||||
<header className="skill-profile-modal__head">
|
||||
<h2 id="skill-profile-modal-title" className="skill-profile-modal__title">
|
||||
{title}
|
||||
</h2>
|
||||
<button type="button" className="btn btn-secondary btn-small" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</header>
|
||||
<SkillProfilePanel
|
||||
profile={data?.overall}
|
||||
slots={artifactType === 'framework_program' ? data?.slots : null}
|
||||
loading={loading}
|
||||
error={error}
|
||||
title="Vollständiges Profil"
|
||||
displayMode="full"
|
||||
embedded
|
||||
defaultExpanded
|
||||
artifactType={artifactType}
|
||||
/>
|
||||
{scale && !loading ? (
|
||||
<p className="form-sub skill-profile-modal__scale">
|
||||
Vergleichsbasis: {scale.artifacts_scanned ?? 0} sichtbare {peerCountLabel} (
|
||||
{scale.skills_in_corpus ?? 0} Fähigkeiten mit Referenz).
|
||||
{scale.description ? ` ${scale.description}` : null}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
338
frontend/src/components/skills/SkillProfilePanel.jsx
Normal file
338
frontend/src/components/skills/SkillProfilePanel.jsx
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
formatClubPercent,
|
||||
peerPercentSuffix,
|
||||
} from '../../utils/skillProfileListHelpers'
|
||||
|
||||
function skillWeight(skill) {
|
||||
return Number(skill?.weight ?? skill?.score ?? 0)
|
||||
}
|
||||
|
||||
function formatWeight(value) {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return '0'
|
||||
return n % 1 === 0 ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
function barFillPercent(skill, maxWeight, hasReferenceScale) {
|
||||
if (hasReferenceScale && skill?.universal_percent != null) {
|
||||
return Math.min(100, Number(skill.universal_percent))
|
||||
}
|
||||
const w = skillWeight(skill)
|
||||
return maxWeight > 0 ? Math.min(100, (w / maxWeight) * 100) : 0
|
||||
}
|
||||
|
||||
function metricLabel(skill, hasReferenceScale, peerLabel) {
|
||||
if (hasReferenceScale && skill?.universal_percent != null) {
|
||||
const best = skill.is_club_best_for_skill ? ' ★' : ''
|
||||
return `${formatClubPercent(skill.universal_percent)} ${peerLabel}${best}`
|
||||
}
|
||||
return formatWeight(skillWeight(skill))
|
||||
}
|
||||
|
||||
function SkillRow({ skill, maxWeight, hasReferenceScale, peerLabel }) {
|
||||
if (!skill) return null
|
||||
const pct = barFillPercent(skill, maxWeight, hasReferenceScale)
|
||||
return (
|
||||
<li className="skill-profile__row">
|
||||
<div className="skill-profile__row-head">
|
||||
<span className="skill-profile__name" title={skill.skill_name}>
|
||||
{skill.category_name ? (
|
||||
<span className="skill-profile__name-cat">{skill.category_name} · </span>
|
||||
) : null}
|
||||
{skill.skill_name}
|
||||
</span>
|
||||
<span className="skill-profile__pct" title="Trainingsgewicht (gewichtete Minuten)">
|
||||
{metricLabel(skill, hasReferenceScale, peerLabel)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="skill-profile__bar-track" aria-hidden="true">
|
||||
<div className="skill-profile__bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
{hasReferenceScale ? (
|
||||
<span className="skill-profile__meta-hint">
|
||||
Gewicht {formatWeight(skillWeight(skill))}
|
||||
{skill.club_best ? (
|
||||
<>
|
||||
{' '}
|
||||
· Top unter {peerLabel}: {skill.club_best.artifact_title || skill.club_best.artifact_id} (
|
||||
{formatWeight(skill.club_best.weight)})
|
||||
</>
|
||||
) : skill.is_club_best_for_skill ? (
|
||||
' · Stärkster unter ' + peerLabel
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryTopSkill({ skill, maxWeight, hasReferenceScale, peerLabel }) {
|
||||
if (!skill) return null
|
||||
return (
|
||||
<SkillRow
|
||||
skill={skill}
|
||||
maxWeight={maxWeight}
|
||||
hasReferenceScale={hasReferenceScale}
|
||||
peerLabel={peerLabel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryGroupedProfile({ profile, ariaLabel, peerLabel }) {
|
||||
const groups = profile?.by_main_category || []
|
||||
const hasReferenceScale = Boolean(profile?.has_reference_scale)
|
||||
const maxWeight = useMemo(() => {
|
||||
let max = 1
|
||||
for (const mc of groups) {
|
||||
for (const cat of mc.categories || []) {
|
||||
const w = skillWeight(cat.top_skill)
|
||||
if (w > max) max = w
|
||||
}
|
||||
}
|
||||
return max
|
||||
}, [groups])
|
||||
|
||||
if (!groups.length) return null
|
||||
|
||||
return (
|
||||
<div className="skill-profile__by-category" aria-label={ariaLabel}>
|
||||
{groups.map((mc) => (
|
||||
<section key={mc.main_category_id ?? mc.main_category_name} className="skill-profile__main-cat">
|
||||
<h4 className="skill-profile__main-cat-title">{mc.main_category_name}</h4>
|
||||
<ul className="skill-profile__cat-list">
|
||||
{(mc.categories || []).map((cat) => (
|
||||
<li key={cat.category_id ?? cat.category_name} className="skill-profile__cat-item">
|
||||
<span className="skill-profile__cat-label">{cat.category_name}</span>
|
||||
<div className="skill-profile__cat-row">
|
||||
<CategoryTopSkill
|
||||
skill={cat.top_skill}
|
||||
maxWeight={maxWeight}
|
||||
hasReferenceScale={hasReferenceScale}
|
||||
peerLabel={peerLabel}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FullSkillsProfile({ profile, ariaLabel, peerLabel }) {
|
||||
const skills = profile?.skills || []
|
||||
const hasReferenceScale = Boolean(profile?.has_reference_scale)
|
||||
const maxWeight = useMemo(
|
||||
() => Math.max(...skills.map((s) => skillWeight(s)), 1),
|
||||
[skills]
|
||||
)
|
||||
|
||||
if (!skills.length) return null
|
||||
|
||||
return (
|
||||
<ul className="skill-profile__list" aria-label={ariaLabel}>
|
||||
{skills.map((sk) => (
|
||||
<SkillRow
|
||||
key={sk.skill_id}
|
||||
skill={sk}
|
||||
maxWeight={maxWeight}
|
||||
hasReferenceScale={hasReferenceScale}
|
||||
peerLabel={peerLabel}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function topCategoryBadge(profile) {
|
||||
const parts = []
|
||||
for (const mc of profile?.by_main_category || []) {
|
||||
for (const cat of mc.categories || []) {
|
||||
const top = cat.top_skill
|
||||
if (!top) continue
|
||||
parts.push(`${cat.category_name}: ${top.skill_name}`)
|
||||
if (parts.length >= 2) return parts.join(' · ')
|
||||
}
|
||||
}
|
||||
return parts.join(' · ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gewichtetes Fähigkeiten-Profil (Phase 3) — Anzeige für Planungsartefakte.
|
||||
* displayMode: 'summary' = Top je Kategorie (Editor), 'full' = alle Fähigkeiten (Modal).
|
||||
*/
|
||||
export default function SkillProfilePanel({
|
||||
profile,
|
||||
slots = null,
|
||||
loading = false,
|
||||
error = '',
|
||||
title = 'Fähigkeiten-Profil',
|
||||
hint = '',
|
||||
defaultExpanded = true,
|
||||
displayMode = 'summary',
|
||||
embedded = false,
|
||||
artifactType = 'framework_program',
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
const [slotOpenId, setSlotOpenId] = useState(null)
|
||||
|
||||
const peerLabel = peerPercentSuffix(artifactType)
|
||||
|
||||
const defaultHint =
|
||||
displayMode === 'full'
|
||||
? `Alle verknüpften Fähigkeiten nach Trainingsgewicht. Prozent = Anteil am stärksten sichtbaren Eintrag unter ${peerLabel} je Fähigkeit.`
|
||||
: `Trainingsgewicht aus Dauer, Häufigkeit, Intensität und Stufen-Spanne. Prozent vergleicht nur unter ${peerLabel}, nicht mit anderen Planungs-Artefakttypen.`
|
||||
|
||||
const hintText = hint || defaultHint
|
||||
const badge = useMemo(() => topCategoryBadge(profile), [profile])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={'skill-profile skill-profile--loading' + (embedded ? '' : ' card')}>
|
||||
<p className="skill-profile__status">Fähigkeiten-Profil wird berechnet…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={'skill-profile skill-profile--error' + (embedded ? '' : ' card')}>
|
||||
<p className="skill-profile__status">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const noData =
|
||||
!profile ||
|
||||
(profile.exercise_occurrence_count === 0 && profile.distinct_exercise_count === 0)
|
||||
|
||||
const categoryCount = (profile?.by_main_category || []).reduce(
|
||||
(n, mc) => n + (mc.categories?.length || 0),
|
||||
0
|
||||
)
|
||||
|
||||
const body = (
|
||||
<div className="skill-profile__body">
|
||||
<p className="form-sub skill-profile__hint">{hintText}</p>
|
||||
|
||||
{noData ? (
|
||||
<p className="skill-profile__empty">
|
||||
Noch keine Übungen mit Fähigkeiten-Verknüpfung — lege Übungen im Ablauf an und verknüpfe
|
||||
Fähigkeiten in der Übungsbearbeitung.
|
||||
</p>
|
||||
) : profile.exercises_with_skills_count === 0 ? (
|
||||
<p className="skill-profile__empty">
|
||||
{profile.distinct_exercise_count} Übung{profile.distinct_exercise_count === 1 ? '' : 'en'} im
|
||||
Ablauf, aber keine Fähigkeiten an den Übungen hinterlegt.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="skill-profile__stats">
|
||||
<span>
|
||||
<strong>{profile.distinct_exercise_count}</strong> Übungen
|
||||
</span>
|
||||
<span>
|
||||
<strong>{profile.skills?.length ?? 0}</strong> Fähigkeiten
|
||||
</span>
|
||||
{displayMode === 'summary' ? (
|
||||
<span>
|
||||
<strong>{categoryCount}</strong> Kategorien
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
<strong>{formatWeight(profile.total_score ?? profile.total_weight)}</strong> Gesamt-Gewicht
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{displayMode === 'full' ? (
|
||||
<FullSkillsProfile
|
||||
profile={profile}
|
||||
ariaLabel="Alle Fähigkeiten nach Gewicht"
|
||||
peerLabel={peerLabel}
|
||||
/>
|
||||
) : (
|
||||
<CategoryGroupedProfile
|
||||
profile={profile}
|
||||
ariaLabel="Top-Fähigkeit je Kategorie"
|
||||
peerLabel={peerLabel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{slots && slots.length > 0 ? (
|
||||
<div className="skill-profile__slots">
|
||||
<span className="skill-profile__slots-label">Pro Session</span>
|
||||
<ul className="skill-profile__slot-list">
|
||||
{slots.map((sl) => {
|
||||
const open = slotOpenId === sl.slot_id
|
||||
const slotBadge = topCategoryBadge(sl.profile)
|
||||
return (
|
||||
<li key={sl.slot_id} className="skill-profile__slot-item">
|
||||
<button
|
||||
type="button"
|
||||
className="skill-profile__slot-btn"
|
||||
onClick={() => setSlotOpenId(open ? null : sl.slot_id)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span>
|
||||
{(sl.slot_title || '').trim() || `Session ${(sl.sort_order ?? 0) + 1}`}
|
||||
</span>
|
||||
{slotBadge ? (
|
||||
<span className="skill-profile__slot-top">{slotBadge}</span>
|
||||
) : (
|
||||
<span className="skill-profile__slot-top skill-profile__slot-top--muted">
|
||||
{sl.exercise_occurrence_count > 0 ? 'ohne Fähigkeiten' : 'kein Ablauf'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && sl.profile?.skills?.length > 0 ? (
|
||||
displayMode === 'full' ? (
|
||||
<FullSkillsProfile
|
||||
profile={sl.profile}
|
||||
ariaLabel={`Alle Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
|
||||
peerLabel={peerLabel}
|
||||
/>
|
||||
) : (
|
||||
<CategoryGroupedProfile
|
||||
profile={sl.profile}
|
||||
ariaLabel={`Fähigkeiten Session ${(sl.sort_order ?? 0) + 1}`}
|
||||
peerLabel={peerLabel}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return <div className="skill-profile skill-profile--embedded">{body}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card skill-profile">
|
||||
<button
|
||||
type="button"
|
||||
className="skill-profile__toggle"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<span className="skill-profile__toggle-title">{title}</span>
|
||||
{!noData && badge ? (
|
||||
<span className="skill-profile__toggle-badge">{badge}</span>
|
||||
) : null}
|
||||
<span className="skill-profile__toggle-icon" aria-hidden="true">
|
||||
{expanded ? '▾' : '▸'}
|
||||
</span>
|
||||
</button>
|
||||
{expanded ? body : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,10 +2,12 @@ import React, { useState, useEffect } from 'react'
|
|||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
import SkillDiscoveryPanel from '../components/skills/SkillDiscoveryPanel'
|
||||
|
||||
const SKILLS_SECTION_TABS = [
|
||||
{ id: 'skills', label: 'Fähigkeiten' },
|
||||
{ id: 'methods', label: 'Trainingsmethoden' },
|
||||
{ id: 'discovery', label: 'Planungs-Vorschläge' },
|
||||
]
|
||||
|
||||
function SkillsPage() {
|
||||
|
|
@ -243,6 +245,8 @@ function SkillsPage() {
|
|||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'discovery' && <SkillDiscoveryPanel skills={skills} />}
|
||||
|
||||
{/* Methods Tab */}
|
||||
{activeTab === 'methods' && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import ExercisePeekModal from '../components/ExercisePeekModal'
|
|||
import TrainingUnitSectionsEditor from '../components/TrainingUnitSectionsEditor'
|
||||
import PageSectionNav from '../components/PageSectionNav'
|
||||
import PageFormEditorChrome from '../components/PageFormEditorChrome'
|
||||
import SkillProfilePanel from '../components/skills/SkillProfilePanel'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { useNavReturn } from '../hooks/useNavReturn'
|
||||
import {
|
||||
|
|
@ -248,6 +249,10 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
)
|
||||
/** Schmale Ansicht: welcher Session-Slot gerade die volle Breite nutzt (Chip-Navigation) */
|
||||
const [mobileSlotIdx, setMobileSlotIdx] = useState(0)
|
||||
const [skillProfileData, setSkillProfileData] = useState(null)
|
||||
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
|
||||
const [skillProfileError, setSkillProfileError] = useState('')
|
||||
const [skillProfileTick, setSkillProfileTick] = useState(0)
|
||||
|
||||
const toast = useToast()
|
||||
const baselineRef = useRef(null)
|
||||
|
|
@ -301,6 +306,32 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
return () => document.removeEventListener('pointerdown', onPointerDown, true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew || !idParam) {
|
||||
setSkillProfileData(null)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setSkillProfileLoading(true)
|
||||
setSkillProfileError('')
|
||||
try {
|
||||
const data = await api.getFrameworkProgramSkillProfile(idParam)
|
||||
if (!cancelled) setSkillProfileData(data)
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setSkillProfileData(null)
|
||||
setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setSkillProfileLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isNew, idParam, skillProfileTick])
|
||||
|
||||
const loadMeta = useCallback(async () => {
|
||||
try {
|
||||
const [cl, fa, sd, tt, tg] = await Promise.all([
|
||||
|
|
@ -480,6 +511,7 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
setBypassDirty(false)
|
||||
setBaselineReady(true)
|
||||
toast.success('Gespeichert.')
|
||||
setSkillProfileTick((t) => t + 1)
|
||||
if (closeAfter) goBack()
|
||||
return true
|
||||
} catch (e) {
|
||||
|
|
@ -940,6 +972,17 @@ export default function TrainingFrameworkProgramEditPage() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{!isNew ? (
|
||||
<SkillProfilePanel
|
||||
title="Fähigkeiten-Schwerpunkte (aus Übungen)"
|
||||
profile={skillProfileData?.overall}
|
||||
slots={skillProfileData?.slots}
|
||||
loading={skillProfileLoading}
|
||||
error={skillProfileError}
|
||||
artifactType="framework_program"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={
|
||||
'framework-edit__panel framework-edit__panel--meta card' +
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import api from '../utils/api'
|
|||
import NavStateLink from '../components/NavStateLink'
|
||||
import FrameworkProgramsFilterBlock from '../components/planning/FrameworkProgramsFilterBlock'
|
||||
import FrameworkProgramListCard from '../components/planning/FrameworkProgramListCard'
|
||||
import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||
import { buildFrameworkProgramsListReturnContext } from '../utils/navReturnContext'
|
||||
|
|
@ -22,12 +23,15 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
const [catalogFocusAreas, setCatalogFocusAreas] = useState([])
|
||||
const [catalogTrainingTypes, setCatalogTrainingTypes] = useState([])
|
||||
const [catalogTargetGroups, setCatalogTargetGroups] = useState([])
|
||||
const [catalogSkills, setCatalogSkills] = useState([])
|
||||
const [filters, setFilters] = useState(() => ({ ...EMPTY_FRAMEWORK_IMPORT_FILTERS }))
|
||||
const [filterPanelOpen, setFilterPanelOpen] = useState(true)
|
||||
const [skillSummaries, setSkillSummaries] = useState({})
|
||||
const [summariesLoading, setSummariesLoading] = useState(false)
|
||||
const [profileModal, setProfileModal] = useState(null)
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => filterFrameworkPrograms(rows, filters),
|
||||
[rows, filters]
|
||||
() => filterFrameworkPrograms(rows, filters, skillSummaries),
|
||||
[rows, filters, skillSummaries]
|
||||
)
|
||||
const filterActive = hasActiveFrameworkImportFilters(filters)
|
||||
|
||||
|
|
@ -35,22 +39,25 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [list, fa, tt, tg] = await Promise.all([
|
||||
const [list, fa, tt, tg, skills] = await Promise.all([
|
||||
api.listTrainingFrameworkPrograms(),
|
||||
api.listFocusAreas({ status: 'active' }),
|
||||
api.listTrainingTypes({ status: 'active' }),
|
||||
api.listTargetGroups({ status: 'active' }),
|
||||
api.listSkillsCatalog({ status: 'active' }),
|
||||
])
|
||||
setRows(Array.isArray(list) ? list : [])
|
||||
setCatalogFocusAreas(Array.isArray(fa) ? fa : [])
|
||||
setCatalogTrainingTypes(Array.isArray(tt) ? tt : [])
|
||||
setCatalogTargetGroups(Array.isArray(tg) ? tg : [])
|
||||
setCatalogSkills(Array.isArray(skills) ? skills : [])
|
||||
} catch (e) {
|
||||
setError(e.message || 'Laden fehlgeschlagen')
|
||||
setRows([])
|
||||
setCatalogFocusAreas([])
|
||||
setCatalogTrainingTypes([])
|
||||
setCatalogTargetGroups([])
|
||||
setCatalogSkills([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -60,6 +67,29 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
load()
|
||||
}, [load, tenantClubDepKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!rows.length) {
|
||||
setSkillSummaries({})
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
setSummariesLoading(true)
|
||||
api
|
||||
.batchSkillProfileSummaries({ frameworkProgramIds: rows.map((r) => r.id) })
|
||||
.then((data) => {
|
||||
if (!cancelled) setSkillSummaries(data?.summaries || {})
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSkillSummaries({})
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSummariesLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [rows, tenantClubDepKey])
|
||||
|
||||
async function handleDelete(id, title) {
|
||||
if (!confirm(`Rahmenprogramm „${title || id}“ wirklich löschen?`)) return
|
||||
try {
|
||||
|
|
@ -131,11 +161,11 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
programs={rows}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
panelOpen={filterPanelOpen}
|
||||
onPanelOpenChange={setFilterPanelOpen}
|
||||
catalogFocusAreas={catalogFocusAreas}
|
||||
catalogTrainingTypes={catalogTrainingTypes}
|
||||
catalogTargetGroups={catalogTargetGroups}
|
||||
catalogSkills={catalogSkills}
|
||||
skillSummaries={skillSummaries}
|
||||
durationRadioName="fw-list-duration-mode"
|
||||
className="fw-prog-filter-block--list"
|
||||
/>
|
||||
|
|
@ -157,6 +187,17 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
row={r}
|
||||
returnContext={frameworkListReturn}
|
||||
onDelete={handleDelete}
|
||||
skillSummary={skillSummaries[`framework_program:${r.id}`]}
|
||||
skillSummaryLoading={summariesLoading}
|
||||
skillFilterIds={filters.skillIds || []}
|
||||
skillDisplayLimit={filters.skillDisplayLimit || 10}
|
||||
onShowSkillProfile={(row) =>
|
||||
setProfileModal({
|
||||
artifactType: 'framework_program',
|
||||
artifactId: row.id,
|
||||
title: (row.title || '').trim() || `Rahmen #${row.id}`,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
|
|
@ -164,6 +205,14 @@ export default function TrainingFrameworkProgramsListPage() {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SkillProfileFullModal
|
||||
open={Boolean(profileModal)}
|
||||
onClose={() => setProfileModal(null)}
|
||||
artifactType={profileModal?.artifactType}
|
||||
artifactId={profileModal?.artifactId}
|
||||
title={profileModal?.title}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
|||
import api from '../utils/api'
|
||||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||
import FormActionBar from '../components/FormActionBar'
|
||||
import SkillProfilePanel from '../components/skills/SkillProfilePanel'
|
||||
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
|
|
@ -76,6 +77,10 @@ export default function TrainingModuleEditPage() {
|
|||
const [methods, setMethods] = useState([])
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [skillProfileData, setSkillProfileData] = useState(null)
|
||||
const [skillProfileLoading, setSkillProfileLoading] = useState(false)
|
||||
const [skillProfileError, setSkillProfileError] = useState('')
|
||||
const [skillProfileTick, setSkillProfileTick] = useState(0)
|
||||
|
||||
const [title, setTitle] = useState('')
|
||||
const [summary, setSummary] = useState('')
|
||||
|
|
@ -130,6 +135,32 @@ export default function TrainingModuleEditPage() {
|
|||
const blocker = useUnsavedChangesBlocker(Boolean(formDirtyEffective && !saving))
|
||||
useBeforeUnloadWhen(Boolean(formDirtyEffective && !saving))
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew || !Number.isFinite(moduleId)) {
|
||||
setSkillProfileData(null)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setSkillProfileLoading(true)
|
||||
setSkillProfileError('')
|
||||
try {
|
||||
const data = await api.getTrainingModuleSkillProfile(moduleId)
|
||||
if (!cancelled) setSkillProfileData(data)
|
||||
} catch (e) {
|
||||
if (!cancelled) {
|
||||
setSkillProfileData(null)
|
||||
setSkillProfileError(e.message || 'Fähigkeiten-Profil nicht geladen')
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setSkillProfileLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isNew, moduleId, skillProfileTick])
|
||||
|
||||
const { user } = useAuth()
|
||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
const tenantClubDepKey = useMemo(() => getTenantClubDependencyKey(user), [user])
|
||||
|
|
@ -339,6 +370,7 @@ export default function TrainingModuleEditPage() {
|
|||
baselineRef.current = moduleFormSnapshot(latestFormRef.current)
|
||||
setBypassDirty(false)
|
||||
toast.success('Gespeichert.')
|
||||
setSkillProfileTick((t) => t + 1)
|
||||
if (closeAfter) goBack()
|
||||
return true
|
||||
} catch (err) {
|
||||
|
|
@ -391,6 +423,15 @@ export default function TrainingModuleEditPage() {
|
|||
onSubmit={handleSave}
|
||||
>
|
||||
<div className="page-form-shell__scroll">
|
||||
{!isNew ? (
|
||||
<SkillProfilePanel
|
||||
title="Fähigkeiten im Modul"
|
||||
profile={skillProfileData?.overall}
|
||||
loading={skillProfileLoading}
|
||||
error={skillProfileError}
|
||||
artifactType="training_module"
|
||||
/>
|
||||
) : null}
|
||||
<div className="form-row">
|
||||
<label className="form-label">Titel *</label>
|
||||
<input className="form-input" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import api from '../utils/api'
|
||||
import NavStateLink from '../components/NavStateLink'
|
||||
import TrainingModulesFilterBlock from '../components/planning/TrainingModulesFilterBlock'
|
||||
import SkillProfileCompact from '../components/skills/SkillProfileCompact'
|
||||
import SkillProfileFullModal from '../components/skills/SkillProfileFullModal'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { getTenantClubDependencyKey } from '../utils/activeClub'
|
||||
import { buildTrainingModulesListReturnContext } from '../utils/navReturnContext'
|
||||
import {
|
||||
EMPTY_TRAINING_MODULE_FILTERS,
|
||||
filterTrainingModules,
|
||||
hasActiveTrainingModuleFilters,
|
||||
} from '../utils/trainingModuleListHelpers'
|
||||
|
||||
export default function TrainingModulesListPage() {
|
||||
const { user } = useAuth()
|
||||
|
|
@ -12,16 +20,32 @@ export default function TrainingModulesListPage() {
|
|||
const [rows, setRows] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [catalogSkills, setCatalogSkills] = useState([])
|
||||
const [filters, setFilters] = useState(() => ({ ...EMPTY_TRAINING_MODULE_FILTERS }))
|
||||
const [skillSummaries, setSkillSummaries] = useState({})
|
||||
const [summariesLoading, setSummariesLoading] = useState(false)
|
||||
const [profileModal, setProfileModal] = useState(null)
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => filterTrainingModules(rows, filters, skillSummaries),
|
||||
[rows, filters, skillSummaries]
|
||||
)
|
||||
const filterActive = hasActiveTrainingModuleFilters(filters)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const list = await api.listTrainingModules()
|
||||
const [list, skills] = await Promise.all([
|
||||
api.listTrainingModules(),
|
||||
api.listSkillsCatalog({ status: 'active' }),
|
||||
])
|
||||
setRows(Array.isArray(list) ? list : [])
|
||||
setCatalogSkills(Array.isArray(skills) ? skills : [])
|
||||
} catch (e) {
|
||||
setError(e.message || 'Laden fehlgeschlagen')
|
||||
setRows([])
|
||||
setCatalogSkills([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
|
@ -31,6 +55,29 @@ export default function TrainingModulesListPage() {
|
|||
load()
|
||||
}, [load, tenantClubDepKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (!rows.length) {
|
||||
setSkillSummaries({})
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
setSummariesLoading(true)
|
||||
api
|
||||
.batchSkillProfileSummaries({ trainingModuleIds: rows.map((r) => r.id) })
|
||||
.then((data) => {
|
||||
if (!cancelled) setSkillSummaries(data?.summaries || {})
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setSkillSummaries({})
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setSummariesLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [rows, tenantClubDepKey])
|
||||
|
||||
async function handleDelete(id, title) {
|
||||
if (!confirm(`Trainingsmodul „${title || id}“ wirklich löschen?`)) return
|
||||
try {
|
||||
|
|
@ -58,8 +105,8 @@ export default function TrainingModulesListPage() {
|
|||
Trainingsmodule
|
||||
</h1>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '0.95rem', maxWidth: '38rem', margin: 0 }}>
|
||||
Wiederverwendbare Übungsfolgen für die Trainingsplanung. Übernahme in eine Einheit erfolgt dort als
|
||||
lokale Kopie (mit Herkunftsmarkierung).
|
||||
Wiederverwendbare Übungsfolgen für die Trainingsplanung. Prozentwerte vergleichen Module nur unter
|
||||
sichtbaren Modulen.
|
||||
</p>
|
||||
</div>
|
||||
<NavStateLink
|
||||
|
|
@ -82,60 +129,118 @@ export default function TrainingModulesListPage() {
|
|||
<p style={{ margin: 0, color: 'var(--text2)' }}>Noch keine Module angelegt.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{rows.map((r) => (
|
||||
<li key={r.id} className="card" style={{ padding: '1rem 1.15rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
gap: '10px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: '1 1 220px', minWidth: 0 }}>
|
||||
<NavStateLink
|
||||
to={`/planning/training-modules/${r.id}`}
|
||||
returnContext={modulesListReturn}
|
||||
<>
|
||||
<TrainingModulesFilterBlock
|
||||
modules={rows}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
catalogSkills={catalogSkills}
|
||||
skillSummaries={skillSummaries}
|
||||
className="fw-prog-filter-block--list"
|
||||
/>
|
||||
|
||||
{filteredRows.length === 0 ? (
|
||||
<div className="card" style={{ padding: '1.25rem' }}>
|
||||
<h2 style={{ margin: '0 0 0.5rem', fontSize: '1.05rem' }}>Kein Treffer</h2>
|
||||
<p style={{ margin: 0, color: 'var(--text2)' }}>
|
||||
{filterActive
|
||||
? 'Kein Modul passt zu den gewählten Filtern. Passe die Kriterien an oder setze den Filter zurück.'
|
||||
: 'Keine Einträge.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul
|
||||
style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '10px' }}
|
||||
>
|
||||
{filteredRows.map((r) => (
|
||||
<li key={r.id} className="card" style={{ padding: '1rem 1.15rem' }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: '1.05rem',
|
||||
color: 'var(--accent-dark)',
|
||||
textDecoration: 'none',
|
||||
wordBreak: 'break-word',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
gap: '10px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{(r.title || '').trim() || `Modul #${r.id}`}
|
||||
</NavStateLink>
|
||||
<p style={{ margin: '0.35rem 0 0', fontSize: '0.88rem', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||||
{(r.summary || '').trim() || '—'}{' '}
|
||||
<span style={{ color: 'var(--text3)' }}>
|
||||
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
|
||||
</span>
|
||||
</p>
|
||||
<p style={{ margin: '0.35rem 0 0', fontSize: '0.8rem', color: 'var(--text3)' }}>
|
||||
Sichtbarkeit: <strong>{r.visibility || '—'}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<NavStateLink
|
||||
to={`/planning/training-modules/${r.id}`}
|
||||
returnContext={modulesListReturn}
|
||||
className="btn btn-secondary"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
Bearbeiten
|
||||
</NavStateLink>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div style={{ flex: '1 1 220px', minWidth: 0 }}>
|
||||
<NavStateLink
|
||||
to={`/planning/training-modules/${r.id}`}
|
||||
returnContext={modulesListReturn}
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: '1.05rem',
|
||||
color: 'var(--accent-dark)',
|
||||
textDecoration: 'none',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{(r.title || '').trim() || `Modul #${r.id}`}
|
||||
</NavStateLink>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.35rem 0 0',
|
||||
fontSize: '0.88rem',
|
||||
color: 'var(--text2)',
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
{(r.summary || '').trim() || '—'}{' '}
|
||||
<span style={{ color: 'var(--text3)' }}>
|
||||
({Number(r.items_count) || 0} Position{r.items_count === 1 ? '' : 'en'})
|
||||
</span>
|
||||
</p>
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<SkillProfileCompact
|
||||
summary={skillSummaries[`training_module:${r.id}`]}
|
||||
artifactType="training_module"
|
||||
loading={summariesLoading}
|
||||
displayLimit={filters.skillDisplayLimit || 12}
|
||||
highlightSkillIds={filters.skillIds || []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-small"
|
||||
onClick={() =>
|
||||
setProfileModal({
|
||||
artifactType: 'training_module',
|
||||
artifactId: r.id,
|
||||
title: (r.title || '').trim() || `Modul #${r.id}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
Fähigkeiten-Profil
|
||||
</button>
|
||||
<NavStateLink
|
||||
to={`/planning/training-modules/${r.id}`}
|
||||
returnContext={modulesListReturn}
|
||||
className="btn btn-secondary"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
Bearbeiten
|
||||
</NavStateLink>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => handleDelete(r.id, r.title)}>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<SkillProfileFullModal
|
||||
open={Boolean(profileModal)}
|
||||
onClose={() => setProfileModal(null)}
|
||||
artifactType={profileModal?.artifactType}
|
||||
artifactId={profileModal?.artifactId}
|
||||
title={profileModal?.title}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@
|
|||
import { request, ACTIVE_CLUB_STORAGE_KEY } from '../api/client.js'
|
||||
import * as exercises from '../api/exercises.js'
|
||||
import * as planning from '../api/planning.js'
|
||||
import * as skillProfiles from '../api/skillProfiles.js'
|
||||
|
||||
export { ACTIVE_CLUB_STORAGE_KEY }
|
||||
export * from '../api/exercises.js'
|
||||
export * from '../api/planning.js'
|
||||
export * from '../api/skillProfiles.js'
|
||||
|
||||
// ============================================================================
|
||||
// Auth
|
||||
|
|
@ -753,6 +755,9 @@ export const api = {
|
|||
// Training Planning → frontend/src/api/planning.js
|
||||
...planning,
|
||||
|
||||
// Fähigkeiten-Profile & Vorschläge (Phase 3) → frontend/src/api/skillProfiles.js
|
||||
...skillProfiles,
|
||||
|
||||
// Catalogs
|
||||
listFocusAreas,
|
||||
createFocusArea,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { formatDurationDisplay, formatSessionDurationRange } from './trainingDurationUtils'
|
||||
import {
|
||||
frameworkSkillSummaryKey,
|
||||
maxSelectedSkillClubPercent,
|
||||
summaryHasSkill,
|
||||
} from './skillProfileListHelpers'
|
||||
|
||||
export function frameworkSessionDurationLabel(row) {
|
||||
return formatSessionDurationRange(row?.session_duration_min, row?.session_duration_max, {
|
||||
|
|
@ -109,6 +114,10 @@ export const EMPTY_FRAMEWORK_IMPORT_FILTERS = {
|
|||
durationRangeFrom: '',
|
||||
durationRangeTo: '',
|
||||
durationPresetMin: null,
|
||||
skillIds: [],
|
||||
skillSort: 'title',
|
||||
skillMinClubPercent: 0,
|
||||
skillDisplayLimit: 24,
|
||||
}
|
||||
|
||||
export function hasActiveFrameworkImportFilters(filters = {}) {
|
||||
|
|
@ -122,6 +131,9 @@ export function hasActiveFrameworkImportFilters(filters = {}) {
|
|||
if (String(f.durationRangeTo || '').trim() !== '') return true
|
||||
}
|
||||
if (f.durationMode === 'preset' && f.durationPresetMin != null) return true
|
||||
if ((f.skillIds || []).length) return true
|
||||
if (Number(f.skillMinClubPercent) > 0) return true
|
||||
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) return true
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -136,6 +148,17 @@ export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) {
|
|||
|
||||
const nameById = (list, id) => list?.find((x) => String(x.id) === String(id))?.name || id
|
||||
|
||||
if ((f.skillIds || []).length) {
|
||||
const names = f.skillIds.map((id) => nameById(catalogs.skills, id))
|
||||
parts.push(`Fähigkeiten: ${names.join(', ')}`)
|
||||
}
|
||||
if (Number(f.skillMinClubPercent) > 0) {
|
||||
parts.push(`mind. ${f.skillMinClubPercent}% vom Rahmenprogramm-Maximum`)
|
||||
}
|
||||
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) {
|
||||
parts.push('Sortierung: Fähigkeiten-Stärke')
|
||||
}
|
||||
|
||||
if ((f.focusAreaIds || []).length) {
|
||||
const names = f.focusAreaIds.map((id) => nameById(catalogs.focusAreas, id))
|
||||
parts.push(`Fokus: ${names.join(', ')}`)
|
||||
|
|
@ -167,14 +190,16 @@ export function summarizeFrameworkImportFilters(filters = {}, catalogs = {}) {
|
|||
/**
|
||||
* Client-Filter für Rahmenprogramm-Auswahl (Liste / Import-Dialog).
|
||||
*/
|
||||
export function filterFrameworkPrograms(rows, filters = {}) {
|
||||
export function filterFrameworkPrograms(rows, filters = {}, skillSummaries = null) {
|
||||
const f = { ...EMPTY_FRAMEWORK_IMPORT_FILTERS, ...filters }
|
||||
const q = (f.query || '').trim().toLowerCase()
|
||||
const focusIds = new Set((f.focusAreaIds || []).map(String))
|
||||
const typeIds = new Set((f.trainingTypeIds || []).map(String))
|
||||
const tgIds = new Set((f.targetGroupIds || []).map(String))
|
||||
const skillIds = f.skillIds || []
|
||||
const minClubPct = Number(f.skillMinClubPercent) || 0
|
||||
|
||||
return (rows || []).filter((r) => {
|
||||
let list = (rows || []).filter((r) => {
|
||||
if (q) {
|
||||
const blob = [
|
||||
r.title,
|
||||
|
|
@ -212,6 +237,26 @@ export function filterFrameworkPrograms(rows, filters = {}) {
|
|||
|
||||
return true
|
||||
})
|
||||
|
||||
if (skillIds.length && skillSummaries) {
|
||||
list = list.filter((r) => {
|
||||
const summary = skillSummaries[frameworkSkillSummaryKey(r.id)]
|
||||
if (!summary) return false
|
||||
return skillIds.some((sid) => summaryHasSkill(summary, sid, minClubPct))
|
||||
})
|
||||
}
|
||||
|
||||
if (f.skillSort === 'skill_strength' && skillIds.length && skillSummaries) {
|
||||
list = [...list].sort((a, b) => {
|
||||
const sa = skillSummaries[frameworkSkillSummaryKey(a.id)]
|
||||
const sb = skillSummaries[frameworkSkillSummaryKey(b.id)]
|
||||
const pa = maxSelectedSkillClubPercent(sa, skillIds) ?? -1
|
||||
const pb = maxSelectedSkillClubPercent(sb, skillIds) ?? -1
|
||||
return pb - pa
|
||||
})
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
|
||||
export function frameworkProgramOptionLabel(row) {
|
||||
|
|
|
|||
137
frontend/src/utils/planningArtifactFilterChips.js
Normal file
137
frontend/src/utils/planningArtifactFilterChips.js
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { formatDurationDisplay } from './trainingDurationUtils'
|
||||
import { EMPTY_FRAMEWORK_IMPORT_FILTERS } from './frameworkProgramListHelpers'
|
||||
import { EMPTY_TRAINING_MODULE_FILTERS } from './trainingModuleListHelpers'
|
||||
import { peerCorpusCountLabel } from './skillProfileListHelpers'
|
||||
|
||||
function nameById(list, id) {
|
||||
return list?.find((x) => String(x.id) === String(id))?.name || id
|
||||
}
|
||||
|
||||
function peerPercentLabel(artifactType) {
|
||||
const label = peerCorpusCountLabel(artifactType)
|
||||
return label === 'Rahmenprogramme' ? 'Rahmenprogramm-Maximum' : `${label}-Maximum`
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernbare Filter-Chips (UX wie Übungsliste).
|
||||
*/
|
||||
export function buildPlanningArtifactFilterChips({
|
||||
filters,
|
||||
setFilters,
|
||||
catalogs = {},
|
||||
artifactType = 'framework_program',
|
||||
emptyFilters = null,
|
||||
}) {
|
||||
const base =
|
||||
emptyFilters ||
|
||||
(artifactType === 'training_module' ? EMPTY_TRAINING_MODULE_FILTERS : EMPTY_FRAMEWORK_IMPORT_FILTERS)
|
||||
const f = { ...base, ...filters }
|
||||
const chips = []
|
||||
const peerMaxLabel = peerPercentLabel(artifactType)
|
||||
|
||||
const q = (f.query || '').trim()
|
||||
if (q) {
|
||||
chips.push({
|
||||
key: 'query',
|
||||
label: `Suche: „${q}"`,
|
||||
onRemove: () => setFilters((prev) => ({ ...prev, query: '' })),
|
||||
})
|
||||
}
|
||||
|
||||
;(f.skillIds || []).forEach((id) => {
|
||||
chips.push({
|
||||
key: `skill-${id}`,
|
||||
label: `Fähigkeit: ${nameById(catalogs.skills, id)}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
skillIds: (prev.skillIds || []).filter((x) => String(x) !== String(id)),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
if (Number(f.skillMinClubPercent) > 0) {
|
||||
chips.push({
|
||||
key: 'skill-min-pct',
|
||||
label: `mind. ${f.skillMinClubPercent}% vom ${peerMaxLabel}`,
|
||||
onRemove: () => setFilters((prev) => ({ ...prev, skillMinClubPercent: 0 })),
|
||||
})
|
||||
}
|
||||
|
||||
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) {
|
||||
chips.push({
|
||||
key: 'skill-sort',
|
||||
label: 'Sortierung: Fähigkeiten-Stärke',
|
||||
onRemove: () => setFilters((prev) => ({ ...prev, skillSort: 'title' })),
|
||||
})
|
||||
}
|
||||
|
||||
;(f.focusAreaIds || []).forEach((id) => {
|
||||
chips.push({
|
||||
key: `focus-${id}`,
|
||||
label: `Fokus: ${nameById(catalogs.focusAreas, id)}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
focusAreaIds: (prev.focusAreaIds || []).filter((x) => String(x) !== String(id)),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
;(f.trainingTypeIds || []).forEach((id) => {
|
||||
chips.push({
|
||||
key: `type-${id}`,
|
||||
label: `Trainingsart: ${nameById(catalogs.trainingTypes, id)}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
trainingTypeIds: (prev.trainingTypeIds || []).filter((x) => String(x) !== String(id)),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
;(f.targetGroupIds || []).forEach((id) => {
|
||||
chips.push({
|
||||
key: `tg-${id}`,
|
||||
label: `Zielgruppe: ${nameById(catalogs.targetGroups, id)}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
targetGroupIds: (prev.targetGroupIds || []).filter((x) => String(x) !== String(id)),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
if (f.durationMode === 'range') {
|
||||
const a = String(f.durationRangeFrom || '').trim()
|
||||
const b = String(f.durationRangeTo || '').trim()
|
||||
if (a || b) {
|
||||
const fromLbl = a ? formatDurationDisplay(Number(a), { empty: a }) : '—'
|
||||
const toLbl = b ? formatDurationDisplay(Number(b), { empty: b }) : '—'
|
||||
chips.push({
|
||||
key: 'duration-range',
|
||||
label: `Dauer: ${fromLbl} – ${toLbl}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
durationMode: 'any',
|
||||
durationRangeFrom: '',
|
||||
durationRangeTo: '',
|
||||
})),
|
||||
})
|
||||
}
|
||||
} else if (f.durationMode === 'preset' && f.durationPresetMin != null) {
|
||||
chips.push({
|
||||
key: 'duration-preset',
|
||||
label: `Dauer: ${formatDurationDisplay(f.durationPresetMin)}`,
|
||||
onRemove: () =>
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
durationMode: 'any',
|
||||
durationPresetMin: null,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return chips
|
||||
}
|
||||
110
frontend/src/utils/skillProfileListHelpers.js
Normal file
110
frontend/src/utils/skillProfileListHelpers.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
export function frameworkSkillSummaryKey(id) {
|
||||
return `framework_program:${id}`
|
||||
}
|
||||
|
||||
export function moduleSkillSummaryKey(id) {
|
||||
return `training_module:${id}`
|
||||
}
|
||||
|
||||
export function skillEntryFromSummary(summary, skillId) {
|
||||
if (!summary) return null
|
||||
const sid = String(skillId)
|
||||
const fromSkills = (summary.skills || []).find((s) => String(s.skill_id) === sid)
|
||||
if (fromSkills) return fromSkills
|
||||
return (summary.top_by_category || []).find((s) => String(s.skill_id) === sid) || null
|
||||
}
|
||||
|
||||
export function summaryHasSkill(summary, skillId, minClubPct = 0) {
|
||||
const sk = skillEntryFromSummary(summary, skillId)
|
||||
if (!sk || !(Number(sk.weight) > 0)) return false
|
||||
const pct = sk.universal_percent
|
||||
if (pct == null) return minClubPct === 0
|
||||
return pct >= minClubPct
|
||||
}
|
||||
|
||||
export function maxSelectedSkillClubPercent(summary, skillIds = []) {
|
||||
if (!summary || !skillIds.length) return null
|
||||
let max = null
|
||||
for (const id of skillIds) {
|
||||
const sk = skillEntryFromSummary(summary, id)
|
||||
if (!sk) continue
|
||||
const pct = sk.universal_percent
|
||||
if (pct == null) continue
|
||||
if (max == null || pct > max) max = pct
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
export function formatClubPercent(value) {
|
||||
if (value == null || !Number.isFinite(Number(value))) return '—'
|
||||
const n = Math.min(100, Number(value))
|
||||
return n % 1 === 0 ? `${n}%` : `${n.toFixed(1)}%`
|
||||
}
|
||||
|
||||
export function formatSkillWeight(value) {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return '—'
|
||||
return n % 1 === 0 ? String(n) : n.toFixed(1)
|
||||
}
|
||||
|
||||
const PEER_LABELS = {
|
||||
framework_program: 'Rahmenpr.',
|
||||
training_module: 'Module',
|
||||
progression_graph: 'Pfade',
|
||||
}
|
||||
|
||||
const PEER_COUNT_LABELS = {
|
||||
framework_program: 'Rahmenprogramme',
|
||||
training_module: 'Module',
|
||||
progression_graph: 'Regressionspfade',
|
||||
}
|
||||
|
||||
export function peerPercentSuffix(artifactType = 'training_module') {
|
||||
return PEER_LABELS[artifactType] || 'Peers'
|
||||
}
|
||||
|
||||
export function peerCorpusCountLabel(artifactType = 'training_module') {
|
||||
return PEER_COUNT_LABELS[artifactType] || 'Planungs-Artefakte'
|
||||
}
|
||||
|
||||
/** KPI-Zeilen: immer Top je Unterkategorie; bei Skill-Filter nur passende Kategorien. */
|
||||
export function kpiRowsFromSummary(summary, { skillIds = [], limit = 8 } = {}) {
|
||||
if (!summary) return []
|
||||
let rows = (summary.top_by_category || []).map((row) => ({
|
||||
skill_id: row.skill_id,
|
||||
skill_name: row.skill_name,
|
||||
category_name: row.category_name,
|
||||
main_category_name: row.main_category_name,
|
||||
weight: row.weight ?? row.score,
|
||||
universal_percent: row.universal_percent,
|
||||
is_club_best_for_skill: row.is_club_best_for_skill,
|
||||
}))
|
||||
if (skillIds.length) {
|
||||
const wanted = new Set(skillIds.map(String))
|
||||
rows = rows.filter((row) => wanted.has(String(row.skill_id)))
|
||||
}
|
||||
return rows.slice(0, limit)
|
||||
}
|
||||
|
||||
/** @deprecated Nutze kpiRowsFromSummary für Listen */
|
||||
export function compactSkillDisplayRows(summary, opts = {}) {
|
||||
return kpiRowsFromSummary(summary, opts)
|
||||
}
|
||||
|
||||
export function artifactTypeLabel(type) {
|
||||
if (type === 'framework_program') return 'Rahmenprogramm'
|
||||
if (type === 'training_module') return 'Modul'
|
||||
if (type === 'progression_graph') return 'Regressionspfad'
|
||||
return type || 'Artefakt'
|
||||
}
|
||||
|
||||
export function artifactPath(ref) {
|
||||
if (!ref) return null
|
||||
if (ref.artifact_type === 'framework_program') {
|
||||
return `/planning/framework-programs/${ref.artifact_id}`
|
||||
}
|
||||
if (ref.artifact_type === 'training_module') {
|
||||
return `/planning/training-modules/${ref.artifact_id}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
57
frontend/src/utils/trainingModuleListHelpers.js
Normal file
57
frontend/src/utils/trainingModuleListHelpers.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import {
|
||||
maxSelectedSkillClubPercent,
|
||||
moduleSkillSummaryKey,
|
||||
summaryHasSkill,
|
||||
} from './skillProfileListHelpers'
|
||||
|
||||
export const EMPTY_TRAINING_MODULE_FILTERS = {
|
||||
query: '',
|
||||
skillIds: [],
|
||||
skillSort: 'title',
|
||||
skillMinClubPercent: 0,
|
||||
skillDisplayLimit: 12,
|
||||
}
|
||||
|
||||
export function hasActiveTrainingModuleFilters(filters = {}) {
|
||||
const f = { ...EMPTY_TRAINING_MODULE_FILTERS, ...filters }
|
||||
if ((f.query || '').trim()) return true
|
||||
if ((f.skillIds || []).length) return true
|
||||
if (Number(f.skillMinClubPercent) > 0) return true
|
||||
if (f.skillSort === 'skill_strength' && (f.skillIds || []).length) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function filterTrainingModules(rows, filters = {}, skillSummaries = null) {
|
||||
const f = { ...EMPTY_TRAINING_MODULE_FILTERS, ...filters }
|
||||
const q = (f.query || '').trim().toLowerCase()
|
||||
const skillIds = f.skillIds || []
|
||||
const minClubPct = Number(f.skillMinClubPercent) || 0
|
||||
|
||||
let list = (rows || []).filter((r) => {
|
||||
if (q) {
|
||||
const blob = [r.title, r.summary].filter(Boolean).join(' ').toLowerCase()
|
||||
if (!blob.includes(q)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
if (skillIds.length && skillSummaries) {
|
||||
list = list.filter((r) => {
|
||||
const summary = skillSummaries[moduleSkillSummaryKey(r.id)]
|
||||
if (!summary) return false
|
||||
return skillIds.some((sid) => summaryHasSkill(summary, sid, minClubPct))
|
||||
})
|
||||
}
|
||||
|
||||
if (f.skillSort === 'skill_strength' && skillIds.length && skillSummaries) {
|
||||
list = [...list].sort((a, b) => {
|
||||
const sa = skillSummaries[moduleSkillSummaryKey(a.id)]
|
||||
const sb = skillSummaries[moduleSkillSummaryKey(b.id)]
|
||||
const pa = maxSelectedSkillClubPercent(sa, skillIds) ?? -1
|
||||
const pb = maxSelectedSkillClubPercent(sb, skillIds) ?? -1
|
||||
return pb - pa
|
||||
})
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user