Implement Exercise Form Enhancements with New Meta Panel
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m14s
- Added a new meta panel for exercise classification and target groups, improving the organization of exercise attributes. - Introduced ExerciseCatalogAssocEditor component to manage focus areas, training styles, and target groups, enhancing user interaction. - Refactored CSS styles for the new meta panel and associated components, ensuring a cohesive design and improved responsiveness. - Removed the MultiAssocBlock component to streamline the code and improve maintainability.
This commit is contained in:
parent
5d308b20ba
commit
9b3f594007
|
|
@ -6548,6 +6548,266 @@ html.modal-scroll-locked .app-main {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Übungsformular — Klassifikation & Meta-Chips */
|
||||
.exercise-form-meta-panel {
|
||||
margin: 4px 0 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface2);
|
||||
}
|
||||
.exercise-form-meta-panel__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.exercise-form-meta-panel__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.exercise-form-meta-panel__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.exercise-meta-block {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.exercise-meta-block--skills {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.exercise-meta-block__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.exercise-meta-block__title {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text1);
|
||||
}
|
||||
.exercise-meta-block__add {
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.exercise-meta-block__hint,
|
||||
.exercise-meta-block__empty {
|
||||
margin: 0 0 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text3);
|
||||
line-height: 1.35;
|
||||
}
|
||||
.exercise-meta-block__empty {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.exercise-meta-block__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.exercise-catalog-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
max-width: 100%;
|
||||
padding: 2px 4px 2px 2px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
}
|
||||
.exercise-catalog-chip__select {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
color: var(--text1);
|
||||
min-width: 0;
|
||||
max-width: min(220px, 72vw);
|
||||
cursor: pointer;
|
||||
}
|
||||
.exercise-catalog-chip__select:focus {
|
||||
outline: none;
|
||||
}
|
||||
.exercise-catalog-chip__primary,
|
||||
.exercise-catalog-chip__remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.exercise-catalog-chip__primary--on {
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
.exercise-catalog-chip__remove:hover,
|
||||
.exercise-catalog-chip__primary:hover {
|
||||
background: color-mix(in srgb, var(--border) 40%, transparent);
|
||||
color: var(--text1);
|
||||
}
|
||||
.exercise-skills-add {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.exercise-skills-add .skill-tree-select {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
.exercise-skills-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.exercise-skill-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface2);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.exercise-skill-chip {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
.exercise-skill-chip__identity {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: min(240px, 100%);
|
||||
}
|
||||
.exercise-skill-chip__name {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.exercise-skill-chip__path {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 11px;
|
||||
color: var(--text3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.exercise-skill-chip__controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 8px 10px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.exercise-skill-chip__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.exercise-skill-chip__caption {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.exercise-skill-chip__levels {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
.exercise-skill-level-select {
|
||||
width: 52px;
|
||||
min-width: 52px;
|
||||
padding: 5px 6px;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.exercise-skill-chip__dash {
|
||||
padding-bottom: 7px;
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.exercise-intensity-segment {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--surface);
|
||||
}
|
||||
.exercise-intensity-segment__btn {
|
||||
padding: 5px 8px;
|
||||
border: none;
|
||||
border-right: 1px solid var(--border);
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
font-family: inherit;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.exercise-intensity-segment__btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.exercise-intensity-segment__btn--active {
|
||||
background: color-mix(in srgb, var(--accent) 16%, var(--surface));
|
||||
color: var(--accent-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
.exercise-skill-chip__remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-left: auto;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--surface);
|
||||
color: var(--text3);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.exercise-skill-chip__remove:hover {
|
||||
color: var(--danger);
|
||||
border-color: color-mix(in srgb, var(--danger) 35%, var(--border));
|
||||
}
|
||||
|
||||
.skills-editor-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Kompakte Katalog-Zuordnung (Fokus, Stile, Zielgruppen …) als Chip-Zeilen.
|
||||
*/
|
||||
export default function ExerciseCatalogAssocEditor({
|
||||
title,
|
||||
rows,
|
||||
setRows,
|
||||
options,
|
||||
idKey,
|
||||
emptyLabel,
|
||||
showPrimary = true,
|
||||
}) {
|
||||
const setPrimary = (idx) => {
|
||||
setRows(rows.map((r, i) => ({ ...r, is_primary: i === idx })))
|
||||
}
|
||||
const updateRow = (idx, patch) => {
|
||||
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r))
|
||||
if (patch.is_primary === true) {
|
||||
next.forEach((r, i) => {
|
||||
if (i !== idx) r.is_primary = false
|
||||
})
|
||||
}
|
||||
setRows(next)
|
||||
}
|
||||
const addRow = () => setRows([...rows, { [idKey]: '', is_primary: rows.length === 0 }])
|
||||
const removeRow = (idx) => {
|
||||
const next = rows.filter((_, i) => i !== idx)
|
||||
if (next.length && showPrimary && !next.some((r) => r.is_primary)) next[0].is_primary = true
|
||||
setRows(next)
|
||||
}
|
||||
|
||||
const optionLabel = (o) => {
|
||||
const parts = []
|
||||
if (o.icon) parts.push(o.icon)
|
||||
parts.push(o.name)
|
||||
if (o.abbreviation) parts.push(`(${o.abbreviation})`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="exercise-meta-block">
|
||||
<div className="exercise-meta-block__head">
|
||||
<h4 className="exercise-meta-block__title">{title}</h4>
|
||||
<button type="button" className="btn btn-secondary exercise-meta-block__add" onClick={addRow}>
|
||||
+ Eintrag
|
||||
</button>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<p className="exercise-meta-block__empty">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="exercise-meta-block__chips" role="list">
|
||||
{rows.map((row, idx) => (
|
||||
<div key={idx} className="exercise-catalog-chip" role="listitem">
|
||||
<select
|
||||
className="exercise-catalog-chip__select"
|
||||
value={row[idKey] || ''}
|
||||
aria-label={`${title} wählen`}
|
||||
onChange={(e) =>
|
||||
updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })
|
||||
}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{optionLabel(o)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{showPrimary ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`exercise-catalog-chip__primary${row.is_primary ? ' exercise-catalog-chip__primary--on' : ''}`}
|
||||
title={row.is_primary ? 'Primär' : 'Als primär markieren'}
|
||||
aria-pressed={!!row.is_primary}
|
||||
onClick={() => setPrimary(idx)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="exercise-catalog-chip__remove"
|
||||
aria-label="Entfernen"
|
||||
title="Entfernen"
|
||||
onClick={() => removeRow(idx)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -14,9 +14,9 @@ import {
|
|||
buildExerciseMediaDragPayload,
|
||||
} from '../../utils/exerciseInlineMediaRefs'
|
||||
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
||||
import SkillTreeSelect from '../SkillTreeSelect'
|
||||
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
||||
import { normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
||||
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
|
||||
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
|
||||
import { useAuth } from '../../context/AuthContext'
|
||||
import { useToast } from '../../context/ToastContext'
|
||||
import {
|
||||
|
|
@ -41,7 +41,6 @@ import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUn
|
|||
|
||||
import {
|
||||
EXERCISE_SKILL_INTENSITY_DEFAULT,
|
||||
EXERCISE_SKILL_INTENSITY_OPTIONS,
|
||||
normalizeExerciseSkillIntensity,
|
||||
} from '../../constants/exerciseSkillIntensity'
|
||||
|
||||
|
|
@ -409,71 +408,6 @@ function detailToForm(exercise) {
|
|||
}
|
||||
}
|
||||
|
||||
function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) {
|
||||
const setPrimary = (idx) => {
|
||||
setRows(rows.map((r, i) => ({ ...r, is_primary: i === idx })))
|
||||
}
|
||||
const updateRow = (idx, patch) => {
|
||||
const next = rows.map((r, i) => (i === idx ? { ...r, ...patch } : r))
|
||||
if (patch.is_primary === true) {
|
||||
next.forEach((r, i) => {
|
||||
if (i !== idx) r.is_primary = false
|
||||
})
|
||||
}
|
||||
setRows(next)
|
||||
}
|
||||
const addRow = () => setRows([...rows, { [idKey]: '', is_primary: rows.length === 0 }])
|
||||
const removeRow = (idx) => {
|
||||
const next = rows.filter((_, i) => i !== idx)
|
||||
if (next.length && !next.some((r) => r.is_primary)) next[0].is_primary = true
|
||||
setRows(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="multi-assoc-block">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '8px' }}>
|
||||
<h3>{title}</h3>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 10px' }} onClick={addRow}>
|
||||
+ Eintrag
|
||||
</button>
|
||||
</div>
|
||||
{rows.length === 0 && (
|
||||
<p style={{ fontSize: '13px', color: 'var(--text2)', margin: 0 }}>{emptyLabel}</p>
|
||||
)}
|
||||
{rows.map((row, idx) => (
|
||||
<div key={idx} className="multi-assoc-row">
|
||||
<select
|
||||
className="form-input"
|
||||
value={row[idKey] || ''}
|
||||
onChange={(e) => updateRow(idx, { [idKey]: e.target.value ? parseInt(e.target.value, 10) : '' })}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{options.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.icon ? `${o.icon} ` : ''}
|
||||
{o.name}
|
||||
{o.abbreviation ? ` (${o.abbreviation})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', fontSize: '13px', whiteSpace: 'nowrap' }}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`primary-${idKey}`}
|
||||
checked={!!row.is_primary}
|
||||
onChange={() => setPrimary(idx)}
|
||||
/>
|
||||
primär
|
||||
</label>
|
||||
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeRow(idx)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExerciseFormPageRoot() {
|
||||
const { id: routeId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
|
|
@ -1806,114 +1740,62 @@ function ExerciseFormPageRoot() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<MultiAssocBlock
|
||||
title="Fokusbereiche (0…n, ein „primär“)"
|
||||
rows={formData.focus_areas_multi}
|
||||
setRows={(r) => updateFormField('focus_areas_multi', r)}
|
||||
options={focusAreas}
|
||||
idKey="focus_area_id"
|
||||
emptyLabel="Keine Zuordnung — optional „+ Eintrag“."
|
||||
/>
|
||||
|
||||
<MultiAssocBlock
|
||||
title="Stilrichtungen (0…n, z. B. Shotokan)"
|
||||
rows={formData.training_styles_multi}
|
||||
setRows={(r) => updateFormField('training_styles_multi', r)}
|
||||
options={styleDirections.map((sd) => ({
|
||||
...sd,
|
||||
name: sd.parent_style_name ? `${sd.name} (${sd.parent_style_name})` : sd.name,
|
||||
}))}
|
||||
idKey="training_style_id"
|
||||
emptyLabel="Keine Stilrichtung gewählt."
|
||||
/>
|
||||
|
||||
<MultiAssocBlock
|
||||
title="Trainingsstil (0…n, z. B. Breitensport / Leistungssport)"
|
||||
rows={formData.training_types_multi}
|
||||
setRows={(r) => updateFormField('training_types_multi', r)}
|
||||
options={trainingTypes}
|
||||
idKey="training_type_id"
|
||||
emptyLabel="Kein Trainingsstil gewählt."
|
||||
/>
|
||||
|
||||
<MultiAssocBlock
|
||||
title="Zielgruppen (0…n)"
|
||||
rows={formData.target_groups_multi}
|
||||
setRows={(r) => updateFormField('target_groups_multi', r)}
|
||||
options={targetGroups}
|
||||
idKey="target_group_id"
|
||||
emptyLabel="Keine Zielgruppe gewählt."
|
||||
/>
|
||||
|
||||
<div className="form-row">
|
||||
<label className="form-label">Fähigkeiten (je Übung mehrere, mit Niveau)</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '10px', alignItems: 'stretch' }}>
|
||||
<SkillTreeSelect
|
||||
value={skillPick}
|
||||
onChange={setSkillPick}
|
||||
skills={skillsCatalog}
|
||||
excludeIds={formData.skills.map((s) => s.skill_id)}
|
||||
placeholder="Fähigkeit wählen…"
|
||||
<section className="exercise-form-meta-panel" aria-labelledby="exercise-meta-heading">
|
||||
<h3 id="exercise-meta-heading" className="exercise-form-meta-panel__title">
|
||||
Klassifikation & Zielgruppe
|
||||
</h3>
|
||||
<div className="exercise-form-meta-panel__grid">
|
||||
<ExerciseCatalogAssocEditor
|
||||
title="Fokusbereiche"
|
||||
rows={formData.focus_areas_multi}
|
||||
setRows={(r) => updateFormField('focus_areas_multi', r)}
|
||||
options={focusAreas}
|
||||
idKey="focus_area_id"
|
||||
emptyLabel="Optional — „+ Eintrag“."
|
||||
/>
|
||||
|
||||
<ExerciseCatalogAssocEditor
|
||||
title="Stilrichtungen"
|
||||
rows={formData.training_styles_multi}
|
||||
setRows={(r) => updateFormField('training_styles_multi', r)}
|
||||
options={styleDirections.map((sd) => ({
|
||||
...sd,
|
||||
name: sd.parent_style_name ? `${sd.name} (${sd.parent_style_name})` : sd.name,
|
||||
}))}
|
||||
idKey="training_style_id"
|
||||
emptyLabel="Optional."
|
||||
/>
|
||||
|
||||
<ExerciseCatalogAssocEditor
|
||||
title="Trainingsstil"
|
||||
rows={formData.training_types_multi}
|
||||
setRows={(r) => updateFormField('training_types_multi', r)}
|
||||
options={trainingTypes}
|
||||
idKey="training_type_id"
|
||||
emptyLabel="Optional."
|
||||
/>
|
||||
|
||||
<ExerciseCatalogAssocEditor
|
||||
title="Zielgruppen"
|
||||
rows={formData.target_groups_multi}
|
||||
setRows={(r) => updateFormField('target_groups_multi', r)}
|
||||
options={targetGroups}
|
||||
idKey="target_group_id"
|
||||
emptyLabel="Optional."
|
||||
showPrimary={false}
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={addSkillRow}>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{formData.skills.map((row, idx) => {
|
||||
const sk = skillsCatalog.find((s) => s.id === row.skill_id)
|
||||
return (
|
||||
<div key={`${row.skill_id}-${idx}`} className="skills-editor-row">
|
||||
<div>
|
||||
<strong style={{ fontSize: '14px' }}>{sk?.name || `Skill #${row.skill_id}`}</strong>
|
||||
{sk ? (
|
||||
<span style={{ color: 'var(--text2)', fontSize: '12px', marginLeft: '6px' }}>
|
||||
{skillCatalogPathLabel(sk)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="exercise-filter-skill-level-field">
|
||||
<span className="exercise-filter-skill-level-caption">Intensität</span>
|
||||
<select
|
||||
className="form-input"
|
||||
value={normalizeExerciseSkillIntensity(row.intensity)}
|
||||
onChange={(e) => updateSkillField(idx, 'intensity', e.target.value)}
|
||||
>
|
||||
{EXERCISE_SKILL_INTENSITY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={row.required_level || ''}
|
||||
onChange={(e) => updateSkillField(idx, 'required_level', e.target.value)}
|
||||
>
|
||||
{SKILL_LEVEL_OPTIONS.map((o) => (
|
||||
<option key={`r-${o.value}`} value={o.value}>
|
||||
von {o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="form-input"
|
||||
value={row.target_level || ''}
|
||||
onChange={(e) => updateSkillField(idx, 'target_level', e.target.value)}
|
||||
>
|
||||
{SKILL_LEVEL_OPTIONS.map((o) => (
|
||||
<option key={`t-${o.value}`} value={o.value}>
|
||||
bis {o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => removeSkillRow(idx)}>
|
||||
Entf.
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ExerciseSkillsEditor
|
||||
rows={formData.skills}
|
||||
skillsCatalog={skillsCatalog}
|
||||
skillPick={skillPick}
|
||||
onSkillPickChange={setSkillPick}
|
||||
onAdd={addSkillRow}
|
||||
onRemove={removeSkillRow}
|
||||
onUpdateField={updateSkillField}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
|
||||
<div className="form-row">
|
||||
|
|
|
|||
134
frontend/src/components/exercises/ExerciseSkillsEditor.jsx
Normal file
134
frontend/src/components/exercises/ExerciseSkillsEditor.jsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import React from 'react'
|
||||
import SkillTreeSelect from '../SkillTreeSelect'
|
||||
import { skillCatalogPathLabel } from '../../utils/skillCatalogTree'
|
||||
import { SKILL_LEVEL_OPTIONS } from '../../constants/skillLevels'
|
||||
import {
|
||||
EXERCISE_SKILL_INTENSITY_DEFAULT,
|
||||
EXERCISE_SKILL_INTENSITY_OPTIONS,
|
||||
normalizeExerciseSkillIntensity,
|
||||
} from '../../constants/exerciseSkillIntensity'
|
||||
|
||||
export default function ExerciseSkillsEditor({
|
||||
rows,
|
||||
skillsCatalog,
|
||||
skillPick,
|
||||
onSkillPickChange,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onUpdateField,
|
||||
}) {
|
||||
return (
|
||||
<div className="exercise-meta-block exercise-meta-block--skills">
|
||||
<div className="exercise-meta-block__head">
|
||||
<h4 className="exercise-meta-block__title">Fähigkeiten</h4>
|
||||
</div>
|
||||
<p className="exercise-meta-block__hint">Je Übung mehrere Fähigkeiten mit Intensität und Niveau (von–bis).</p>
|
||||
|
||||
<div className="exercise-skills-add">
|
||||
<SkillTreeSelect
|
||||
value={skillPick}
|
||||
onChange={onSkillPickChange}
|
||||
skills={skillsCatalog}
|
||||
excludeIds={rows.map((s) => s.skill_id)}
|
||||
placeholder="Fähigkeit wählen…"
|
||||
/>
|
||||
<button type="button" className="btn btn-secondary" onClick={onAdd}>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="exercise-meta-block__empty">Noch keine Fähigkeit zugeordnet.</p>
|
||||
) : (
|
||||
<ul className="exercise-skills-list">
|
||||
{rows.map((row, idx) => {
|
||||
const sk = skillsCatalog.find((s) => s.id === row.skill_id)
|
||||
const intensity = normalizeExerciseSkillIntensity(row.intensity)
|
||||
return (
|
||||
<li key={`${row.skill_id}-${idx}`} className="exercise-skill-chip">
|
||||
<div className="exercise-skill-chip__identity">
|
||||
<span className="exercise-skill-chip__name">{sk?.name || `Skill #${row.skill_id}`}</span>
|
||||
{sk ? (
|
||||
<span className="exercise-skill-chip__path" title={skillCatalogPathLabel(sk)}>
|
||||
{skillCatalogPathLabel(sk)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="exercise-skill-chip__controls">
|
||||
<div className="exercise-skill-chip__field">
|
||||
<span className="exercise-skill-chip__caption">Intensität</span>
|
||||
<div className="exercise-intensity-segment" role="radiogroup" aria-label="Intensität">
|
||||
{EXERCISE_SKILL_INTENSITY_OPTIONS.map((o) => (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={intensity === o.value}
|
||||
className={`exercise-intensity-segment__btn${
|
||||
intensity === o.value ? ' exercise-intensity-segment__btn--active' : ''
|
||||
}`}
|
||||
onClick={() => onUpdateField(idx, 'intensity', o.value)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="exercise-skill-chip__levels">
|
||||
<label className="exercise-skill-chip__field">
|
||||
<span className="exercise-skill-chip__caption">von</span>
|
||||
<select
|
||||
className="form-input exercise-skill-level-select"
|
||||
value={row.required_level || ''}
|
||||
title="Mindest-Niveau"
|
||||
onChange={(e) => onUpdateField(idx, 'required_level', e.target.value)}
|
||||
>
|
||||
{SKILL_LEVEL_OPTIONS.map((o) => (
|
||||
<option key={`r-${o.value}`} value={o.value} title={o.label}>
|
||||
{o.level != null ? o.level : '–'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className="exercise-skill-chip__dash" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
<label className="exercise-skill-chip__field">
|
||||
<span className="exercise-skill-chip__caption">bis</span>
|
||||
<select
|
||||
className="form-input exercise-skill-level-select"
|
||||
value={row.target_level || ''}
|
||||
title="Ziel-Niveau"
|
||||
onChange={(e) => onUpdateField(idx, 'target_level', e.target.value)}
|
||||
>
|
||||
{SKILL_LEVEL_OPTIONS.map((o) => (
|
||||
<option key={`t-${o.value}`} value={o.value} title={o.label}>
|
||||
{o.level != null ? o.level : '–'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="exercise-skill-chip__remove"
|
||||
aria-label="Fähigkeit entfernen"
|
||||
title="Entfernen"
|
||||
onClick={() => onRemove(idx)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { EXERCISE_SKILL_INTENSITY_DEFAULT }
|
||||
Loading…
Reference in New Issue
Block a user