feat: enhance TrainingFrameworkProgramEditPage with goal management and UI improvements
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 39s

- Introduced new goal chip components for better visual representation and interaction.
- Added hover tooltips for goal chips to display additional information.
- Implemented state management for editing goals and goal menu visibility.
- Updated styles in app.css to support new goal chip design and improve layout consistency.
- Incremented version of TrainingFrameworkProgramEditPage to 1.4.0 to reflect the latest enhancements.
This commit is contained in:
Lars 2026-05-05 12:52:51 +02:00
parent 0971f35402
commit 8f32a6df29
3 changed files with 448 additions and 156 deletions

View File

@ -2856,6 +2856,130 @@ a.analysis-split__nav-item {
border-left: 3px solid var(--accent);
}
.framework-goal-chips__hint {
font-size: 0.78rem;
color: var(--text3);
margin: 0 0 10px;
line-height: 1.45;
}
.framework-goal-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.framework-popmenu-anchor {
position: relative;
display: inline-flex;
align-items: stretch;
vertical-align: middle;
max-width: 100%;
}
.framework-goal-chip-wrap {
border-radius: 999px;
background: var(--surface2);
border: 1px solid var(--border2);
}
.framework-goal-chip {
border: none;
background: transparent;
font: inherit;
padding: 6px 10px 6px 12px;
border-radius: 999px 0 0 999px;
cursor: pointer;
max-width: min(240px, 100%);
text-align: left;
}
.framework-goal-chip--active {
background: var(--accent-light);
}
.framework-goal-chip__text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.88rem;
font-weight: 600;
color: var(--text1);
}
.framework-goal-chip__kebab {
border: none;
background: transparent;
padding: 0 8px;
cursor: pointer;
color: var(--text3);
font-size: 1.05rem;
line-height: 1;
border-left: 1px solid var(--border);
border-radius: 0 999px 999px 0;
}
.framework-goal-chip__kebab:hover,
.framework-goal-chip:hover {
filter: brightness(0.97);
}
.framework-goal-editor {
margin-top: 12px;
padding: 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface2);
}
.framework-popmenu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 30;
margin: 0;
padding: 4px 0;
list-style: none;
min-width: 200px;
max-width: min(300px, calc(100vw - 32px));
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
.framework-popmenu--align-end {
left: auto;
right: 0;
}
.framework-popmenu li {
margin: 0;
}
.framework-popmenu__item {
display: block;
width: 100%;
text-align: left;
border: none;
background: transparent;
font-family: var(--font);
font-size: 13px;
padding: 10px 14px;
cursor: pointer;
color: var(--text1);
}
.framework-popmenu__item:hover {
background: var(--surface2);
}
.framework-popmenu__item--danger {
color: var(--danger);
}
.framework-ctrl.framework-ctrl--xs {
padding: 2px 8px;
font-size: 11px;
@ -3090,16 +3214,45 @@ a.analysis-split__nav-item {
color: var(--text3);
}
.framework-ex-row__toolbar {
.framework-ex-row__row2 {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
align-items: center;
gap: 6px;
gap: 8px;
width: 100%;
min-width: 0;
}
.framework-ex-row__variant-spacer {
flex: 1;
min-width: 0;
}
.framework-ex-row__menu-anchor {
flex: 0 0 auto;
}
.framework-ex-row__kebab {
width: 34px;
height: 34px;
padding: 0;
border-radius: 8px;
border: 1px solid var(--border2);
background: var(--surface);
cursor: pointer;
font-size: 1.1rem;
line-height: 1;
color: var(--text2);
}
.framework-ex-row__kebab:hover {
border-color: var(--accent);
color: var(--accent-dark);
}
.framework-ex-row__variant-select {
flex: 1 1 140px;
min-width: 120px;
flex: 1 1 auto;
min-width: 100px;
max-width: 100%;
padding: 6px 8px;
font-size: 13px;

View File

@ -31,6 +31,15 @@ function emptySlot() {
return { title: '', notes: '', training_unit_id: '', exercises: [] }
}
/** Native-Tooltip für Ziel-Chips (Hover); kurz halten für OS-Tooltip-Limits */
function goalHoverText(g) {
const t = (g.title || '').trim() || 'Ohne Titel'
const n = (g.notes || '').trim()
if (!n) return t
const combined = `${t}${n}`
return combined.length > 280 ? `${combined.slice(0, 277)}` : combined
}
function defaultForm() {
return {
title: '',
@ -190,6 +199,9 @@ export default function TrainingFrameworkProgramEditPage() {
const [units, setUnits] = useState([])
const [pickerSlotIdx, setPickerSlotIdx] = useState(null)
const [peekId, setPeekId] = useState(null)
const [editingGoalIdx, setEditingGoalIdx] = useState(null)
const [goalMenuGi, setGoalMenuGi] = useState(null)
const [exerciseMenu, setExerciseMenu] = useState(null)
/** Nur schmal: Stammdaten | Plan (Ziele übereinander, darunter Slots) — Desktop: alles sichtbar */
const [frameworkTab, setFrameworkTab] = useState('meta')
const [desktopLayout, setDesktopLayout] = useState(
@ -206,6 +218,17 @@ export default function TrainingFrameworkProgramEditPage() {
return () => mq.removeEventListener('change', apply)
}, [])
useEffect(() => {
const onPointerDown = (e) => {
const t = e.target
if (t.closest?.('.framework-popmenu-anchor')) return
setExerciseMenu(null)
setGoalMenuGi(null)
}
document.addEventListener('pointerdown', onPointerDown, true)
return () => document.removeEventListener('pointerdown', onPointerDown, true)
}, [])
const loadMeta = useCallback(async () => {
try {
const [gr, cl] = await Promise.all([
@ -295,6 +318,8 @@ export default function TrainingFrameworkProgramEditPage() {
}
const moveGoal = (idx, dir) => {
setEditingGoalIdx(null)
setGoalMenuGi(null)
setForm((prev) => {
const j = idx + dir
if (j < 0 || j >= prev.goals.length) return prev
@ -304,12 +329,25 @@ export default function TrainingFrameworkProgramEditPage() {
})
}
const addGoal = () => setForm((prev) => ({ ...prev, goals: [...prev.goals, emptyGoal()] }))
const removeGoal = (idx) =>
const addGoal = () => {
let newIdx = 0
setForm((prev) => {
const goals = [...prev.goals, emptyGoal()]
newIdx = goals.length - 1
return { ...prev, goals }
})
setEditingGoalIdx(newIdx)
setGoalMenuGi(null)
}
const removeGoal = (idx) => {
setForm((prev) => {
const g = prev.goals.filter((_, i) => i !== idx)
return { ...prev, goals: g.length ? g : [emptyGoal()] }
})
setEditingGoalIdx(null)
setGoalMenuGi(null)
}
const moveSlot = (idx, dir) => {
setForm((prev) => {
@ -487,33 +525,29 @@ export default function TrainingFrameworkProgramEditPage() {
}
const onExerciseDragStart = (e, fromS, fromE) => {
if (!desktopLayout) return
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(DND_FW_EX, JSON.stringify({ fromS, fromE }))
setExerciseMenu(null)
}
const onExerciseDragOver = (e) => {
if (!desktopLayout) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
const onSlotDragStart = (e, slotIdx) => {
if (!desktopLayout) return
e.stopPropagation()
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(DND_FW_SLOT, JSON.stringify({ slotIdx }))
}
const onSlotColumnDragOver = (e) => {
if (!desktopLayout) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
const onSlotColumnDrop = (e, targetSi) => {
e.preventDefault()
if (!desktopLayout) return
const slotRaw = e.dataTransfer.getData(DND_FW_SLOT)
if (slotRaw) {
const { slotIdx } = JSON.parse(slotRaw)
@ -740,7 +774,7 @@ export default function TrainingFrameworkProgramEditPage() {
gap: '8px',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.75rem',
marginBottom: '0.6rem',
}}
>
<h3 className="card-title" style={{ marginBottom: 0 }}>
@ -750,72 +784,138 @@ export default function TrainingFrameworkProgramEditPage() {
+ Ziel
</button>
</div>
{(form.goals || []).map((g, gi) => (
<div
key={gi}
className="framework-goal-block"
style={{
border: '1px solid var(--border)',
borderRadius: '8px',
padding: '10px',
marginBottom: '10px',
background: 'var(--surface2)',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '4px', marginBottom: '8px' }}>
<p className="framework-goal-chips__hint">
Ziele als Tags Tippen zum Bearbeiten; am Desktop zeigt der <strong>Titel</strong>-Tooltip die Notizen mit. Am
Handy: <strong></strong> oder Bearbeiten.
</p>
<div className="framework-goal-chips">
{(form.goals || []).map((g, gi) => (
<div key={gi} className="framework-popmenu-anchor framework-goal-chip-wrap">
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => moveGoal(gi, -1)}
aria-label="Ziel nach oben"
className={
'framework-goal-chip' + (editingGoalIdx === gi ? ' framework-goal-chip--active' : '')
}
title={goalHoverText(g)}
onClick={() => {
setEditingGoalIdx(gi)
setGoalMenuGi(null)
}}
>
<span className="framework-goal-chip__text">
{(g.title || '').trim() || `Ziel ${gi + 1}`}
</span>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => moveGoal(gi, 1)}
aria-label="Ziel nach unten"
className="framework-goal-chip__kebab"
aria-label="Ziel-Menü"
aria-expanded={goalMenuGi === gi}
onClick={(e) => {
e.stopPropagation()
setGoalMenuGi((prev) => (prev === gi ? null : gi))
}}
>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => removeGoal(gi)}
>
Entfernen
</button>
{goalMenuGi === gi ? (
<ul className="framework-popmenu" role="menu">
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
setEditingGoalIdx(gi)
setGoalMenuGi(null)
}}
>
Bearbeiten
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
moveGoal(gi, -1)
setGoalMenuGi(null)
}}
>
Nach vorn in der Liste
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
moveGoal(gi, 1)
setGoalMenuGi(null)
}}
>
Nach hinten in der Liste
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item framework-popmenu__item--danger"
onClick={() => removeGoal(gi)}
>
Entfernen
</button>
</li>
</ul>
) : null}
</div>
))}
</div>
{editingGoalIdx != null && form.goals[editingGoalIdx] != null ? (
<div className="framework-goal-editor">
<div className="form-row">
<label className="form-label">Titel *</label>
<input
className="form-input"
value={g.title}
value={form.goals[editingGoalIdx].title}
onChange={(e) =>
setForm((prev) => ({
...prev,
goals: prev.goals.map((x, i) => (i === gi ? { ...x, title: e.target.value } : x)),
goals: prev.goals.map((x, i) =>
i === editingGoalIdx ? { ...x, title: e.target.value } : x
),
}))
}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<div className="form-row" style={{ marginBottom: 10 }}>
<label className="form-label">Notizen</label>
<textarea
className="form-input"
rows={2}
value={g.notes}
value={form.goals[editingGoalIdx].notes}
onChange={(e) =>
setForm((prev) => ({
...prev,
goals: prev.goals.map((x, i) => (i === gi ? { ...x, notes: e.target.value } : x)),
goals: prev.goals.map((x, i) =>
i === editingGoalIdx ? { ...x, notes: e.target.value } : x
),
}))
}
/>
</div>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => setEditingGoalIdx(null)}
>
Fertig
</button>
</div>
))}
) : null}
</div>
<div className="card framework-plan-slots" style={{ marginBottom: '1.5rem' }}>
@ -838,9 +938,8 @@ export default function TrainingFrameworkProgramEditPage() {
className="framework-slots-hint"
style={{ fontSize: '0.8rem', color: 'var(--text3)', marginBottom: '10px', lineHeight: 1.45 }}
>
{desktopLayout
? 'Sessions nebeneinander — nach rechts scrollen. Spalten am linken Griff verschieben, Übungen am Zeilen-Griff (Drag & Drop).'
: 'Sessions nebeneinander — nach rechts wischen bzw. scrollen. Reihenfolge: Pfeile bei Slots und Übungen (kein Ziehen).'}
Sessions nebeneinander nach rechts scrollen bzw. wischen. Reihenfolge mit Griff ( / )
per Drag & Drop möglich (Browser-abhängig bei Touch); zusätzlich Pfeile und Menüs nutzbar.
</p>
)}
@ -850,23 +949,21 @@ export default function TrainingFrameworkProgramEditPage() {
<div
key={si}
className="card framework-slot-card"
onDragOver={desktopLayout ? onSlotColumnDragOver : undefined}
onDrop={desktopLayout ? (e) => onSlotColumnDrop(e, si) : undefined}
onDragOver={onSlotColumnDragOver}
onDrop={(e) => onSlotColumnDrop(e, si)}
>
<div className="framework-slot-card__head">
{desktopLayout ? (
<span
role="button"
tabIndex={0}
className="framework-slot-card__drag-handle"
draggable
onDragStart={(e) => onSlotDragStart(e, si)}
aria-label="Slot ziehen: Reihenfolge ändern"
title="Slot ziehen (Reihenfolge)"
>
</span>
) : null}
<span
role="button"
tabIndex={0}
className="framework-slot-card__drag-handle"
draggable
onDragStart={(e) => onSlotDragStart(e, si)}
aria-label="Slot ziehen: Reihenfolge ändern"
title="Slot ziehen (Reihenfolge)"
>
</span>
<div className="framework-slot-card__head-main">
<span className="framework-slot-card__slot-label">Session {si + 1}</span>
<input
@ -954,42 +1051,35 @@ export default function TrainingFrameworkProgramEditPage() {
</div>
{(slot.exercises || []).length === 0 ? (
<p className="framework-slot-card__empty-hint">
Noch keine Übung <strong>+ Übung</strong>
{desktopLayout ? ' oder Zeile hierher ziehen.' : ' antippen.'}
Noch keine Übung <strong>+ Übung</strong> oder Übung mit dem Griff hierher ziehen.
</p>
) : null}
{(slot.exercises || []).map((ex, ei) => (
<div
key={ei}
className="framework-ex-row"
onDragOver={desktopLayout ? onExerciseDragOver : undefined}
onDrop={
desktopLayout
? (e) => {
e.preventDefault()
e.stopPropagation()
const raw = e.dataTransfer.getData(DND_FW_EX)
if (!raw) return
const { fromS, fromE } = JSON.parse(raw)
if (fromS === si && fromE === ei) return
insertExerciseBefore(fromS, fromE, si, ei)
}
: undefined
}
onDragOver={onExerciseDragOver}
onDrop={(e) => {
e.preventDefault()
e.stopPropagation()
const raw = e.dataTransfer.getData(DND_FW_EX)
if (!raw) return
const { fromS, fromE } = JSON.parse(raw)
if (fromS === si && fromE === ei) return
insertExerciseBefore(fromS, fromE, si, ei)
}}
>
{desktopLayout ? (
<span
className="framework-ex-row__grip"
draggable
onDragStart={(e) => {
e.stopPropagation()
onExerciseDragStart(e, si, ei)
}}
aria-hidden
>
</span>
) : null}
<span
className="framework-ex-row__grip"
draggable
onDragStart={(e) => {
e.stopPropagation()
onExerciseDragStart(e, si, ei)
}}
aria-hidden
>
</span>
<div className="framework-ex-row__body">
<div className="framework-ex-row__title-line">
{ex.exercise_id ? (
@ -1005,50 +1095,7 @@ export default function TrainingFrameworkProgramEditPage() {
<span className="framework-ex-row__id">#{ex.exercise_id}</span>
) : null}
</div>
<div className="framework-ex-row__toolbar">
{!desktopLayout ? (
<>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => moveExercise(si, ei, -1)}
aria-label="Übung nach oben"
>
</button>
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => moveExercise(si, ei, 1)}
aria-label="Übung nach unten"
>
</button>
</>
) : null}
<button
type="button"
className="btn btn-primary framework-ctrl framework-ctrl--xs"
onClick={() => setPickerSlotIdx({ slotIdx: si, exerciseIdx: ei })}
>
Wählen
</button>
{ex.exercise_id ? (
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => setPeekId(Number(ex.exercise_id))}
>
Vorschau
</button>
) : null}
<button
type="button"
className="btn btn-secondary framework-ctrl framework-ctrl--xs"
onClick={() => removeExercise(si, ei)}
>
Entf.
</button>
<div className="framework-ex-row__row2">
{ex.exercise_id && (ex.variants || []).length > 0 ? (
<select
className="form-input framework-ex-row__variant-select"
@ -1062,27 +1109,119 @@ export default function TrainingFrameworkProgramEditPage() {
</option>
))}
</select>
) : null}
) : (
<span className="framework-ex-row__variant-spacer" aria-hidden />
)}
<div className="framework-popmenu-anchor framework-ex-row__menu-anchor">
<button
type="button"
className="framework-ex-row__kebab"
aria-label="Übung-Aktionen"
aria-haspopup="menu"
aria-expanded={
exerciseMenu?.slotIdx === si && exerciseMenu?.exIdx === ei ? 'true' : 'false'
}
onClick={(e) => {
e.stopPropagation()
setExerciseMenu((prev) =>
prev?.slotIdx === si && prev?.exIdx === ei
? null
: { slotIdx: si, exIdx: ei }
)
}}
>
</button>
{exerciseMenu?.slotIdx === si && exerciseMenu?.exIdx === ei ? (
<ul className="framework-popmenu framework-popmenu--align-end" role="menu">
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
setPickerSlotIdx({ slotIdx: si, exerciseIdx: ei })
setExerciseMenu(null)
}}
>
Übung wählen
</button>
</li>
{ex.exercise_id ? (
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
setPeekId(Number(ex.exercise_id))
setExerciseMenu(null)
}}
>
Vorschau
</button>
</li>
) : null}
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
moveExercise(si, ei, -1)
setExerciseMenu(null)
}}
>
Nach oben in diesem Slot
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item"
onClick={() => {
moveExercise(si, ei, 1)
setExerciseMenu(null)
}}
>
Nach unten in diesem Slot
</button>
</li>
<li>
<button
type="button"
role="menuitem"
className="framework-popmenu__item framework-popmenu__item--danger"
onClick={() => {
removeExercise(si, ei)
setExerciseMenu(null)
}}
>
Entfernen
</button>
</li>
</ul>
) : null}
</div>
</div>
</div>
</div>
))}
{desktopLayout ? (
<div
className="framework-slot-card__append-drop"
onDragOver={onExerciseDragOver}
onDrop={(e) => {
e.preventDefault()
e.stopPropagation()
const raw = e.dataTransfer.getData(DND_FW_EX)
if (!raw) return
const { fromS, fromE } = JSON.parse(raw)
appendExerciseToSlot(fromS, fromE, si)
}}
>
Ans Ende ziehen
</div>
) : null}
<div
className="framework-slot-card__append-drop"
onDragOver={onExerciseDragOver}
onDrop={(e) => {
e.preventDefault()
e.stopPropagation()
const raw = e.dataTransfer.getData(DND_FW_EX)
if (!raw) return
const { fromS, fromE } = JSON.parse(raw)
appendExerciseToSlot(fromS, fromE, si)
}}
>
Ans Ende ziehen
</div>
</div>
</div>
))}

View File

@ -12,7 +12,7 @@ export const PAGE_VERSIONS = {
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.3.1",
TrainingFrameworkProgramsListPage: "1.0.0",
TrainingFrameworkProgramEditPage: "1.3.0",
TrainingFrameworkProgramEditPage: "1.4.0",
TrainingUnitRunPage: "1.1.0",
TrainingCoachPage: "1.0.0",
AdminCatalogsPage: "2.2.0", // Updated: Frontend API Calls & Field Names für renamed tables