shinkan-jinkendo/frontend/src/components/skills/SkillProfilePanel.jsx

190 lines
6.8 KiB
React
Raw Normal View History

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>
)
}