190 lines
6.8 KiB
React
190 lines
6.8 KiB
React
|
|
import React, { useMemo, useState } from 'react'
|
|||
|
|
|
|||
|
|
function SkillBar({ skill, maxShare }) {
|
|||
|
|
const pct = maxShare > 0 ? Math.min(100, (skill.share_percent / maxShare) * 100) : 0
|
|||
|
|
return (
|
|||
|
|
<li className="skill-profile__row">
|
|||
|
|
<div className="skill-profile__row-head">
|
|||
|
|
<span className="skill-profile__name" title={skill.skill_name}>
|
|||
|
|
{skill.skill_name}
|
|||
|
|
</span>
|
|||
|
|
<span className="skill-profile__pct">{skill.share_percent}%</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="skill-profile__bar-track" aria-hidden="true">
|
|||
|
|
<div
|
|||
|
|
className="skill-profile__bar-fill"
|
|||
|
|
style={{ width: `${pct}%` }}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
{skill.primary_link_count > 0 ? (
|
|||
|
|
<span className="skill-profile__meta-hint">
|
|||
|
|
{skill.primary_link_count}× als Primär-Fähigkeit in Übungen
|
|||
|
|
</span>
|
|||
|
|
) : null}
|
|||
|
|
</li>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Gewichtetes Fähigkeiten-Profil (Phase 3) — Anzeige für Planungsartefakte.
|
|||
|
|
*/
|
|||
|
|
export default function SkillProfilePanel({
|
|||
|
|
profile,
|
|||
|
|
slots = null,
|
|||
|
|
loading = false,
|
|||
|
|
error = '',
|
|||
|
|
title = 'Fähigkeiten-Profil',
|
|||
|
|
hint = 'Aus verknüpften Übungen berechnet (Dauer, Vorkommen, Primär-Fähigkeit, Intensität).',
|
|||
|
|
defaultExpanded = true,
|
|||
|
|
}) {
|
|||
|
|
const [expanded, setExpanded] = useState(defaultExpanded)
|
|||
|
|
const [slotOpenId, setSlotOpenId] = useState(null)
|
|||
|
|
|
|||
|
|
const skills = profile?.skills || []
|
|||
|
|
const maxShare = useMemo(
|
|||
|
|
() => Math.max(...skills.map((s) => s.share_percent || 0), 1),
|
|||
|
|
[skills]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return (
|
|||
|
|
<div className="card skill-profile skill-profile--loading">
|
|||
|
|
<p className="skill-profile__status">Fähigkeiten-Profil wird berechnet…</p>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (error) {
|
|||
|
|
return (
|
|||
|
|
<div className="card skill-profile skill-profile--error">
|
|||
|
|
<p className="skill-profile__status">{error}</p>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const noData =
|
|||
|
|
!profile ||
|
|||
|
|
(profile.exercise_occurrence_count === 0 && profile.distinct_exercise_count === 0)
|
|||
|
|
|
|||
|
|
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 && skills.length > 0 ? (
|
|||
|
|
<span className="skill-profile__toggle-badge">
|
|||
|
|
Top: {skills[0].skill_name} ({skills[0].share_percent}%)
|
|||
|
|
</span>
|
|||
|
|
) : null}
|
|||
|
|
<span className="skill-profile__toggle-icon" aria-hidden="true">
|
|||
|
|
{expanded ? '▾' : '▸'}
|
|||
|
|
</span>
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
{expanded ? (
|
|||
|
|
<div className="skill-profile__body">
|
|||
|
|
<p className="form-sub skill-profile__hint">{hint}</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>{skills.length}</strong> Fähigkeiten
|
|||
|
|
</span>
|
|||
|
|
<span>
|
|||
|
|
<strong>{profile.exercise_occurrence_count}</strong> Positionen
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<ul className="skill-profile__list" aria-label="Fähigkeiten nach Gewicht">
|
|||
|
|
{skills.slice(0, 12).map((sk) => (
|
|||
|
|
<SkillBar key={sk.skill_id} skill={sk} maxShare={maxShare} />
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
|
|||
|
|
{profile.by_category?.length > 1 ? (
|
|||
|
|
<div className="skill-profile__categories">
|
|||
|
|
<span className="skill-profile__categories-label">Nach Kategorie</span>
|
|||
|
|
<div className="skill-profile__category-chips">
|
|||
|
|
{profile.by_category.slice(0, 6).map((c) => (
|
|||
|
|
<span key={c.category} className="skill-profile__category-chip">
|
|||
|
|
{c.category} {c.share_percent}%
|
|||
|
|
</span>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{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 top = sl.profile?.skills?.[0]
|
|||
|
|
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>
|
|||
|
|
{top ? (
|
|||
|
|
<span className="skill-profile__slot-top">
|
|||
|
|
{top.skill_name} {top.share_percent}%
|
|||
|
|
</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 ? (
|
|||
|
|
<ul className="skill-profile__list skill-profile__list--nested">
|
|||
|
|
{sl.profile.skills.slice(0, 6).map((sk) => (
|
|||
|
|
<SkillBar
|
|||
|
|
key={sk.skill_id}
|
|||
|
|
skill={sk}
|
|||
|
|
maxShare={Math.max(
|
|||
|
|
...sl.profile.skills.map((x) => x.share_percent || 0),
|
|||
|
|
1
|
|||
|
|
)}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
) : null}
|
|||
|
|
</li>
|
|||
|
|
)
|
|||
|
|
})}
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|