Module und Kombinationsübnungen in Version 0.8 #31
|
|
@ -404,6 +404,19 @@ _ORIGIN_LINEAGE_FIELDS = """
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_source_training_module_id_payload(raw_val) -> Optional[int]:
|
||||||
|
"""Erlaubt None; sonst positives int (FK-Verletzung bei ungültigem Modul möglich)."""
|
||||||
|
if raw_val is None or raw_val == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
i = int(raw_val)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if i < 1:
|
||||||
|
return None
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -429,10 +442,12 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||||
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
|
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) AS exercise_focus_area,
|
) AS exercise_focus_area,
|
||||||
ev.variant_name AS exercise_variant_name
|
ev.variant_name AS exercise_variant_name,
|
||||||
|
tm.title AS source_module_title
|
||||||
FROM training_unit_section_items tusi
|
FROM training_unit_section_items tusi
|
||||||
LEFT JOIN exercises e ON tusi.exercise_id = e.id
|
LEFT JOIN exercises e ON tusi.exercise_id = e.id
|
||||||
LEFT JOIN exercise_variants ev ON tusi.exercise_variant_id = ev.id
|
LEFT JOIN exercise_variants ev ON tusi.exercise_variant_id = ev.id
|
||||||
|
LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id
|
||||||
WHERE tusi.section_id = %s
|
WHERE tusi.section_id = %s
|
||||||
ORDER BY tusi.order_index
|
ORDER BY tusi.order_index
|
||||||
""",
|
""",
|
||||||
|
|
@ -453,28 +468,32 @@ def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||||
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
|
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
|
||||||
oix = it.get("order_index")
|
oix = it.get("order_index")
|
||||||
if itype == "note":
|
if itype == "note":
|
||||||
items_clean.append(
|
note_item = {
|
||||||
{
|
"item_type": "note",
|
||||||
"item_type": "note",
|
"order_index": oix,
|
||||||
"order_index": oix,
|
"note_body": it.get("note_body") or "",
|
||||||
"note_body": it.get("note_body") or "",
|
}
|
||||||
}
|
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
|
||||||
)
|
if sm is not None:
|
||||||
|
note_item["source_training_module_id"] = sm
|
||||||
|
items_clean.append(note_item)
|
||||||
continue
|
continue
|
||||||
if itype != "exercise" or not it.get("exercise_id"):
|
if itype != "exercise" or not it.get("exercise_id"):
|
||||||
continue
|
continue
|
||||||
items_clean.append(
|
ex_item = {
|
||||||
{
|
"item_type": "exercise",
|
||||||
"item_type": "exercise",
|
"order_index": oix,
|
||||||
"order_index": oix,
|
"exercise_id": it["exercise_id"],
|
||||||
"exercise_id": it["exercise_id"],
|
"exercise_variant_id": it.get("exercise_variant_id"),
|
||||||
"exercise_variant_id": it.get("exercise_variant_id"),
|
"planned_duration_min": it.get("planned_duration_min"),
|
||||||
"planned_duration_min": it.get("planned_duration_min"),
|
"actual_duration_min": it.get("actual_duration_min"),
|
||||||
"actual_duration_min": it.get("actual_duration_min"),
|
"notes": it.get("notes"),
|
||||||
"notes": it.get("notes"),
|
"modifications": it.get("modifications"),
|
||||||
"modifications": it.get("modifications"),
|
}
|
||||||
}
|
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
|
||||||
)
|
if sm is not None:
|
||||||
|
ex_item["source_training_module_id"] = sm
|
||||||
|
items_clean.append(ex_item)
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"title": sec.get("title"),
|
"title": sec.get("title"),
|
||||||
|
|
@ -670,18 +689,19 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
|
||||||
body = raw.get("note_body")
|
body = raw.get("note_body")
|
||||||
if body is None:
|
if body is None:
|
||||||
body = ""
|
body = ""
|
||||||
|
src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO training_unit_section_items (
|
INSERT INTO training_unit_section_items (
|
||||||
section_id, order_index, item_type,
|
section_id, order_index, item_type,
|
||||||
exercise_id, exercise_variant_id,
|
exercise_id, exercise_variant_id,
|
||||||
planned_duration_min, actual_duration_min,
|
planned_duration_min, actual_duration_min,
|
||||||
notes, modifications, note_body
|
notes, modifications, note_body, source_training_module_id
|
||||||
) VALUES (%s, %s, 'note',
|
) VALUES (%s, %s, 'note',
|
||||||
NULL, NULL, NULL, NULL, NULL, NULL, %s
|
NULL, NULL, NULL, NULL, NULL, NULL, %s, %s
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
(section_id, order_ix, body),
|
(section_id, order_ix, body, src_mod),
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -691,15 +711,16 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
|
||||||
eid = int(eid)
|
eid = int(eid)
|
||||||
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
|
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
|
||||||
_validate_variant_for_exercise(cur, eid, vid)
|
_validate_variant_for_exercise(cur, eid, vid)
|
||||||
|
src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id"))
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO training_unit_section_items (
|
INSERT INTO training_unit_section_items (
|
||||||
section_id, order_index, item_type,
|
section_id, order_index, item_type,
|
||||||
exercise_id, exercise_variant_id,
|
exercise_id, exercise_variant_id,
|
||||||
planned_duration_min, actual_duration_min,
|
planned_duration_min, actual_duration_min,
|
||||||
notes, modifications, note_body
|
notes, modifications, note_body, source_training_module_id
|
||||||
) VALUES (%s, %s, 'exercise',
|
) VALUES (%s, %s, 'exercise',
|
||||||
%s, %s, %s, %s, %s, %s, NULL
|
%s, %s, %s, %s, %s, %s, NULL, %s
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
|
|
@ -711,6 +732,7 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
|
||||||
raw.get("actual_duration_min"),
|
raw.get("actual_duration_min"),
|
||||||
raw.get("notes"),
|
raw.get("notes"),
|
||||||
raw.get("modifications"),
|
raw.get("modifications"),
|
||||||
|
src_mod,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5115,6 +5115,19 @@ a.analysis-split__nav-item {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tu-planning-module-band {
|
||||||
|
margin-top: 0.85rem;
|
||||||
|
margin-bottom: 0.05rem;
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border2);
|
||||||
|
background: linear-gradient(to right, var(--accent-light), transparent 140%);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
.tu-item-row {
|
.tu-item-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,12 @@ import {
|
||||||
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
|
const DND_TU_ITEM = 'application/x-shinkan-training-unit-item'
|
||||||
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
|
const DND_TU_SECTION = 'application/x-shinkan-training-section-v1'
|
||||||
|
|
||||||
|
function normalizedPlanningModuleChainId(raw) {
|
||||||
|
if (raw == null || raw === '') return null
|
||||||
|
const n = typeof raw === 'number' ? raw : Number(raw)
|
||||||
|
return Number.isFinite(n) && n >= 1 ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
function dtHasType(e, mime) {
|
function dtHasType(e, mime) {
|
||||||
const t = e?.dataTransfer?.types
|
const t = e?.dataTransfer?.types
|
||||||
if (!t || !mime) return false
|
if (!t || !mime) return false
|
||||||
|
|
@ -521,15 +527,29 @@ export default function TrainingUnitSectionsEditor({
|
||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
|
|
||||||
|
const prevIt = iIdx > 0 ? sec.items[iIdx - 1] : null
|
||||||
|
const curMn = normalizedPlanningModuleChainId(it.source_training_module_id)
|
||||||
|
const showModuleBand =
|
||||||
|
curMn != null && curMn !== normalizedPlanningModuleChainId(prevIt?.source_training_module_id)
|
||||||
|
const modBandTitle =
|
||||||
|
(it.source_module_title || '').trim() ||
|
||||||
|
(curMn != null ? `Modul #${curMn}` : '')
|
||||||
|
|
||||||
if (it.item_type === 'note') {
|
if (it.item_type === 'note') {
|
||||||
const notePv = truncatePreview(it.note_body || '', 260)
|
const notePv = truncatePreview(it.note_body || '', 260)
|
||||||
const noteHasText = Boolean((it.note_body || '').trim())
|
const noteHasText = Boolean((it.note_body || '').trim())
|
||||||
return (
|
return (
|
||||||
<div
|
<Fragment key={`it-${sIdx}-${iIdx}`}>
|
||||||
key={`note-${sIdx}-${iIdx}`}
|
{showModuleBand ? (
|
||||||
className={`${rowCommon} tu-item-row--note`}
|
<div
|
||||||
{...dndRowProps}
|
className="tu-planning-module-band"
|
||||||
>
|
role="group"
|
||||||
|
aria-label={`Baustein ${modBandTitle}`}
|
||||||
|
>
|
||||||
|
Baustein: {modBandTitle}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={`${rowCommon} tu-item-row--note`} {...dndRowProps}>
|
||||||
{enableItemDragReorder ? (
|
{enableItemDragReorder ? (
|
||||||
<span
|
<span
|
||||||
className="tu-row-grip"
|
className="tu-row-grip"
|
||||||
|
|
@ -596,6 +616,7 @@ export default function TrainingUnitSectionsEditor({
|
||||||
✗
|
✗
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -611,11 +632,17 @@ export default function TrainingUnitSectionsEditor({
|
||||||
: Number(it.exercise_variant_id)
|
: Number(it.exercise_variant_id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Fragment key={`it-${sIdx}-${iIdx}`}>
|
||||||
key={`ex-${sIdx}-${iIdx}`}
|
{showModuleBand ? (
|
||||||
className={`${rowCommon} tu-item-row--exercise`}
|
<div
|
||||||
{...dndRowProps}
|
className="tu-planning-module-band"
|
||||||
>
|
role="group"
|
||||||
|
aria-label={`Baustein ${modBandTitle}`}
|
||||||
|
>
|
||||||
|
Baustein: {modBandTitle}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={`${rowCommon} tu-item-row--exercise`} {...dndRowProps}>
|
||||||
<div className="tu-item-row__mainline">
|
<div className="tu-item-row__mainline">
|
||||||
{enableItemDragReorder ? (
|
{enableItemDragReorder ? (
|
||||||
<span
|
<span
|
||||||
|
|
@ -793,6 +820,7 @@ export default function TrainingUnitSectionsEditor({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
</Fragment>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import ExercisePickerModal from '../components/ExercisePickerModal'
|
import ExercisePickerModal from '../components/ExercisePickerModal'
|
||||||
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
|
import { hydrateExercisePlanningRow } from '../utils/trainingUnitSectionsForm'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { activeClubMemberships, getResolvedActiveClubIdForUi } from '../utils/activeClub'
|
||||||
|
|
||||||
function nextLocalKey() {
|
function nextLocalKey() {
|
||||||
return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
return `m-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
@ -38,6 +40,19 @@ export default function TrainingModuleEditPage() {
|
||||||
const [primaryMethodId, setPrimaryMethodId] = useState('')
|
const [primaryMethodId, setPrimaryMethodId] = useState('')
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState([])
|
||||||
|
|
||||||
|
const { user } = useAuth()
|
||||||
|
const clubChoices = useMemo(() => activeClubMemberships(user?.clubs ?? []), [user?.clubs])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isNew || visibility !== 'club') return
|
||||||
|
if ((clubIdField || '').trim() !== '') return
|
||||||
|
if (clubChoices.length === 1) setClubIdField(String(clubChoices[0].id))
|
||||||
|
else {
|
||||||
|
const r = getResolvedActiveClubIdForUi(user)
|
||||||
|
if (r) setClubIdField(String(r))
|
||||||
|
}
|
||||||
|
}, [isNew, visibility, clubIdField, clubChoices, user])
|
||||||
|
|
||||||
const itemsPayload = items.map((it, i) => {
|
const itemsPayload = items.map((it, i) => {
|
||||||
if (it.item_type === 'note') {
|
if (it.item_type === 'note') {
|
||||||
return { item_type: 'note', order_index: i, note_body: it.note_body ?? '' }
|
return { item_type: 'note', order_index: i, note_body: it.note_body ?? '' }
|
||||||
|
|
@ -133,8 +148,16 @@ export default function TrainingModuleEditPage() {
|
||||||
}, [isNew, moduleId])
|
}, [isNew, moduleId])
|
||||||
|
|
||||||
const buildBody = () => {
|
const buildBody = () => {
|
||||||
const cid =
|
let cid = null
|
||||||
visibility === 'club' && clubIdField !== '' ? parseInt(clubIdField, 10) : null
|
if (visibility === 'club') {
|
||||||
|
const raw = (clubIdField || '').trim()
|
||||||
|
if (raw !== '') {
|
||||||
|
const p = parseInt(raw, 10)
|
||||||
|
if (Number.isFinite(p) && p >= 1) cid = p
|
||||||
|
} else if (clubChoices.length === 1) {
|
||||||
|
cid = clubChoices[0].id
|
||||||
|
}
|
||||||
|
}
|
||||||
const pm =
|
const pm =
|
||||||
primaryMethodId !== '' && primaryMethodId != null ? parseInt(primaryMethodId, 10) : null
|
primaryMethodId !== '' && primaryMethodId != null ? parseInt(primaryMethodId, 10) : null
|
||||||
return {
|
return {
|
||||||
|
|
@ -270,22 +293,79 @@ export default function TrainingModuleEditPage() {
|
||||||
<div className="form-row" style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr 1fr' }}>
|
<div className="form-row" style={{ display: 'grid', gap: '1rem', gridTemplateColumns: '1fr 1fr' }}>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Sichtbarkeit</label>
|
<label className="form-label">Sichtbarkeit</label>
|
||||||
<select className="form-input" value={visibility} onChange={(e) => setVisibility(e.target.value)}>
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={visibility}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value
|
||||||
|
setVisibility(v)
|
||||||
|
if (v !== 'club') {
|
||||||
|
setClubIdField('')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const xs = clubChoices
|
||||||
|
if (xs.length === 1) setClubIdField(String(xs[0].id))
|
||||||
|
else if (xs.length === 0) setClubIdField('')
|
||||||
|
else {
|
||||||
|
const resolved = getResolvedActiveClubIdForUi(user)
|
||||||
|
setClubIdField(resolved != null ? String(resolved) : '')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<option value="private">Privat</option>
|
<option value="private">Privat</option>
|
||||||
<option value="club">Vereinsintern</option>
|
<option value="club">Vereinsintern</option>
|
||||||
<option value="official">Offiziell</option>
|
<option value="official">Offiziell</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label">Vereins‑ID (optional, bei Vereins‑Sichtbarkeit)</label>
|
<label className="form-label">Verein (bei „Vereinsintern“)</label>
|
||||||
<input
|
{visibility !== 'club' ? (
|
||||||
className="form-input"
|
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||||
type="number"
|
Bei privaten oder offiziellen Modulen ist keine Vereinszuordnung nötig (Server legt keine
|
||||||
min={1}
|
Vereinsbindung fest).
|
||||||
value={clubIdField}
|
</p>
|
||||||
onChange={(e) => setClubIdField(e.target.value)}
|
) : clubChoices.length === 0 ? (
|
||||||
placeholder="Leer = aktiver Verein (Server)"
|
<p style={{ margin: '0.25rem 0 0', fontSize: '0.85rem', color: 'var(--danger)', lineHeight: 1.45 }}>
|
||||||
/>
|
Kein aktiver Verein im Profil — bitte zuerst einem Verein beitreten.
|
||||||
|
</p>
|
||||||
|
) : clubChoices.length === 1 ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
disabled
|
||||||
|
readOnly
|
||||||
|
value={
|
||||||
|
(clubChoices[0].short_name || clubChoices[0].name || '').trim() ||
|
||||||
|
`Verein #${clubChoices[0].id}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||||
|
Fixiert durch deine Mitgliedschaft. Verein-ID {clubChoices[0].id} wird beim Speichern verwendet.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={clubIdField}
|
||||||
|
onChange={(e) => setClubIdField(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Automatisch (aktueller Verein im Profil)</option>
|
||||||
|
{clubChoices.map((c) => {
|
||||||
|
const ln = `${((c.short_name || c.name || '').trim() || '') || `Verein #${c.id}`}`
|
||||||
|
return (
|
||||||
|
<option key={c.id} value={String(c.id)}>
|
||||||
|
{ln}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
<p style={{ margin: '0.35rem 0 0', fontSize: '0.82rem', color: 'var(--text3)', lineHeight: 1.45 }}>
|
||||||
|
Bei „Automatisch“ entscheidet der aktiv gewählte Verein beim Speichern (wie bei anderen
|
||||||
|
Bibliotheksinhalten).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
enrichSectionsWithVariants,
|
enrichSectionsWithVariants,
|
||||||
buildSectionsPayload,
|
buildSectionsPayload,
|
||||||
hydrateExercisePlanningRow,
|
hydrateExercisePlanningRow,
|
||||||
|
insertTrainingModuleIntoPlanningSections,
|
||||||
} from '../utils/trainingUnitSectionsForm'
|
} from '../utils/trainingUnitSectionsForm'
|
||||||
|
|
||||||
function addDaysIsoDate(isoDay, daysDelta) {
|
function addDaysIsoDate(isoDay, daysDelta) {
|
||||||
|
|
@ -149,6 +150,7 @@ function TrainingPlanningPage() {
|
||||||
const [moduleApplyList, setModuleApplyList] = useState([])
|
const [moduleApplyList, setModuleApplyList] = useState([])
|
||||||
const [moduleApplyModuleId, setModuleApplyModuleId] = useState('')
|
const [moduleApplyModuleId, setModuleApplyModuleId] = useState('')
|
||||||
const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
|
const [moduleApplySectionIx, setModuleApplySectionIx] = useState(0)
|
||||||
|
const [moduleApplyInsertSlot, setModuleApplyInsertSlot] = useState('__end__')
|
||||||
const [moduleApplyErr, setModuleApplyErr] = useState('')
|
const [moduleApplyErr, setModuleApplyErr] = useState('')
|
||||||
|
|
||||||
const [startDate, setStartDate] = useState(today)
|
const [startDate, setStartDate] = useState(today)
|
||||||
|
|
@ -196,6 +198,19 @@ function TrainingPlanningPage() {
|
||||||
return Number.isFinite(c) ? c : null
|
return Number.isFinite(c) ? c : null
|
||||||
}, [groups, formData.group_id])
|
}, [groups, formData.group_id])
|
||||||
|
|
||||||
|
const moduleApplyTargetItems = useMemo(() => {
|
||||||
|
const secs = formData.sections || []
|
||||||
|
if (!secs.length) return []
|
||||||
|
let ix =
|
||||||
|
typeof moduleApplySectionIx === 'number'
|
||||||
|
? moduleApplySectionIx
|
||||||
|
: parseInt(String(moduleApplySectionIx), 10)
|
||||||
|
if (!Number.isFinite(ix)) ix = 0
|
||||||
|
if (ix < 0 || ix >= secs.length) return []
|
||||||
|
const sec = secs[ix]
|
||||||
|
return Array.isArray(sec?.items) ? sec.items : []
|
||||||
|
}, [formData.sections, moduleApplySectionIx])
|
||||||
|
|
||||||
const refreshPlanningSectionMeta = useCallback(async () => {
|
const refreshPlanningSectionMeta = useCallback(async () => {
|
||||||
const next = await enrichSectionsWithVariants(planningFormRef.current.sections)
|
const next = await enrichSectionsWithVariants(planningFormRef.current.sections)
|
||||||
setFormData((prev) => ({ ...prev, sections: next }))
|
setFormData((prev) => ({ ...prev, sections: next }))
|
||||||
|
|
@ -672,6 +687,7 @@ function TrainingPlanningPage() {
|
||||||
const openModuleApplyModal = useCallback(async () => {
|
const openModuleApplyModal = useCallback(async () => {
|
||||||
setModuleApplyErr('')
|
setModuleApplyErr('')
|
||||||
setModuleApplySectionIx(0)
|
setModuleApplySectionIx(0)
|
||||||
|
setModuleApplyInsertSlot('__end__')
|
||||||
setModuleApplyOpen(true)
|
setModuleApplyOpen(true)
|
||||||
try {
|
try {
|
||||||
const list = await api.listTrainingModules()
|
const list = await api.listTrainingModules()
|
||||||
|
|
@ -685,41 +701,48 @@ function TrainingPlanningPage() {
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleApplyTrainingModuleConfirm = useCallback(async () => {
|
const handleApplyTrainingModuleConfirm = useCallback(async () => {
|
||||||
if (!editingUnit?.id) return
|
|
||||||
const mid = parseInt(moduleApplyModuleId, 10)
|
const mid = parseInt(moduleApplyModuleId, 10)
|
||||||
if (!Number.isFinite(mid)) {
|
if (!Number.isFinite(mid)) {
|
||||||
alert('Bitte ein Trainingsmodul wählen.')
|
alert('Bitte ein Trainingsmodul wählen.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let secIx = parseInt(moduleApplySectionIx, 10)
|
let secIx = parseInt(String(moduleApplySectionIx), 10)
|
||||||
if (!Number.isFinite(secIx)) secIx = 0
|
if (!Number.isFinite(secIx)) secIx = 0
|
||||||
if (!formData.sections?.length) {
|
|
||||||
|
const baseSections = planningFormRef.current?.sections ?? formData.sections ?? []
|
||||||
|
if (!baseSections.length) {
|
||||||
alert('Keine Abschnitte im Formular.')
|
alert('Keine Abschnitte im Formular.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (secIx < 0 || secIx >= formData.sections.length) secIx = 0
|
if (secIx < 0 || secIx >= baseSections.length) secIx = 0
|
||||||
|
|
||||||
|
let insertBefore = null
|
||||||
|
if (moduleApplyInsertSlot === '__end__') insertBefore = 'end'
|
||||||
|
else if (moduleApplyInsertSlot === '__start__') insertBefore = 'start'
|
||||||
|
else if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) {
|
||||||
|
const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10)
|
||||||
|
insertBefore = Number.isFinite(zi) ? zi : 'end'
|
||||||
|
} else insertBefore = 'end'
|
||||||
|
|
||||||
setModuleApplyBusy(true)
|
setModuleApplyBusy(true)
|
||||||
setModuleApplyErr('')
|
setModuleApplyErr('')
|
||||||
try {
|
try {
|
||||||
await api.applyTrainingModuleToTrainingUnit(editingUnit.id, {
|
const detail = await api.getTrainingModule(mid)
|
||||||
module_id: mid,
|
let nextSections = await insertTrainingModuleIntoPlanningSections({
|
||||||
section_order_index: secIx,
|
sections: baseSections,
|
||||||
|
moduleDetail: detail,
|
||||||
|
sectionIndex: secIx,
|
||||||
|
insertBeforeItemIndex: insertBefore,
|
||||||
})
|
})
|
||||||
await handleEdit({ id: editingUnit.id })
|
nextSections = await enrichSectionsWithVariants(nextSections)
|
||||||
|
setFormData((fd) => ({ ...fd, sections: nextSections }))
|
||||||
setModuleApplyOpen(false)
|
setModuleApplyOpen(false)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setModuleApplyErr(e.message || 'Übernehmen fehlgeschlagen')
|
setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
setModuleApplyBusy(false)
|
setModuleApplyBusy(false)
|
||||||
}
|
}
|
||||||
}, [
|
}, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot, formData.sections])
|
||||||
editingUnit?.id,
|
|
||||||
moduleApplyModuleId,
|
|
||||||
moduleApplySectionIx,
|
|
||||||
formData.sections?.length,
|
|
||||||
handleEdit,
|
|
||||||
])
|
|
||||||
|
|
||||||
const handleTakeLead = async (unit) => {
|
const handleTakeLead = async (unit) => {
|
||||||
if (!user?.id) return
|
if (!user?.id) return
|
||||||
|
|
@ -1895,11 +1918,12 @@ function TrainingPlanningPage() {
|
||||||
aria-labelledby="module-apply-title"
|
aria-labelledby="module-apply-title"
|
||||||
>
|
>
|
||||||
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
|
<h2 id="module-apply-title" style={{ marginBottom: '0.5rem', fontSize: '1.15rem' }}>
|
||||||
Trainingsmodul übernehmen
|
Modul einfügen
|
||||||
</h2>
|
</h2>
|
||||||
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
<p style={{ color: 'var(--text2)', fontSize: '0.9rem', marginBottom: '1rem', lineHeight: 1.5 }}>
|
||||||
Der Inhalt wird <strong>kopiert</strong> und ans Ende des gewählten Abschnitts angehängt (Herkunft wird
|
Übungen und Notizen des Moduls werden <strong>kopiert</strong> wie bei einer einzelnen Übung —
|
||||||
gespeichert). Anschließend kannst du ihn lokal bearbeiten.
|
ohne die Einheit vorher gespeichert zu haben (Speichern am Ende wie gewohnt). Die Herkunft bleibt
|
||||||
|
am Block sichtbar; du kannst alles weiter anpassen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{moduleApplyErr ? (
|
{moduleApplyErr ? (
|
||||||
|
|
@ -1930,7 +1954,10 @@ function TrainingPlanningPage() {
|
||||||
<select
|
<select
|
||||||
className="form-input"
|
className="form-input"
|
||||||
value={String(moduleApplySectionIx)}
|
value={String(moduleApplySectionIx)}
|
||||||
onChange={(e) => setModuleApplySectionIx(parseInt(e.target.value, 10))}
|
onChange={(e) => {
|
||||||
|
setModuleApplySectionIx(parseInt(e.target.value, 10))
|
||||||
|
setModuleApplyInsertSlot('__end__')
|
||||||
|
}}
|
||||||
disabled={moduleApplyBusy || !formData.sections?.length}
|
disabled={moduleApplyBusy || !formData.sections?.length}
|
||||||
>
|
>
|
||||||
{(formData.sections || []).map((s, i) => (
|
{(formData.sections || []).map((s, i) => (
|
||||||
|
|
@ -1941,6 +1968,32 @@ function TrainingPlanningPage() {
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Position in diesem Abschnitt</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={moduleApplyInsertSlot}
|
||||||
|
onChange={(e) => setModuleApplyInsertSlot(e.target.value)}
|
||||||
|
disabled={moduleApplyBusy || !(formData.sections?.length > 0)}
|
||||||
|
>
|
||||||
|
<option value="__end__">Ans Ende einfügen (nach allen Einträgen)</option>
|
||||||
|
<option value="__start__">An den Anfang (vor dem ersten Eintrag)</option>
|
||||||
|
{moduleApplyTargetItems.map((row, xi) => {
|
||||||
|
const labelPart =
|
||||||
|
row.item_type === 'note'
|
||||||
|
? 'Zwischen-Anmerkung'
|
||||||
|
: (row.exercise_title || '').trim() || `Übung #${row.exercise_id || '—'}`
|
||||||
|
const clipped =
|
||||||
|
labelPart.length > 44 ? `${labelPart.slice(0, 43).trim()}…` : labelPart
|
||||||
|
return (
|
||||||
|
<option key={`before-${xi}`} value={`before:${xi}`}>
|
||||||
|
Vor Eintrag {xi + 1}: {clipped}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -1959,7 +2012,7 @@ function TrainingPlanningPage() {
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-primary" disabled={moduleApplyBusy} onClick={handleApplyTrainingModuleConfirm}>
|
<button type="button" className="btn btn-primary" disabled={moduleApplyBusy} onClick={handleApplyTrainingModuleConfirm}>
|
||||||
{moduleApplyBusy ? 'Übernehmen …' : 'Übernehmen'}
|
{moduleApplyBusy ? 'Einfügen …' : 'Einfügen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -2473,16 +2526,14 @@ function TrainingPlanningPage() {
|
||||||
<button type="button" className="btn btn-secondary" onClick={handleSaveAsTemplate}>
|
<button type="button" className="btn btn-secondary" onClick={handleSaveAsTemplate}>
|
||||||
Vorlage aus Aufbau speichern
|
Vorlage aus Aufbau speichern
|
||||||
</button>
|
</button>
|
||||||
{editingUnit?.id ? (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
className="btn btn-secondary"
|
||||||
className="btn btn-secondary"
|
onClick={openModuleApplyModal}
|
||||||
onClick={openModuleApplyModal}
|
title="Modulpositionen hier einfügen (Kopie, auch ohne zwischengespeicherte Einheit)"
|
||||||
title="Übungen und Notizen aus einem Modul ans Ende eines Abschnitts kopieren"
|
>
|
||||||
>
|
Modul einfügen…
|
||||||
Aus Modul übernehmen…
|
</button>
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
sections={formData.sections}
|
sections={formData.sections}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ export function exerciseRow() {
|
||||||
actual_duration_min: '',
|
actual_duration_min: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
modifications: '',
|
modifications: '',
|
||||||
|
source_training_module_id: '',
|
||||||
|
source_module_title: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,7 +71,14 @@ export async function hydrateExercisePlanningRow(exercise) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function noteRow() {
|
export function noteRow() {
|
||||||
return { item_type: 'note', note_body: '' }
|
return { item_type: 'note', note_body: '', source_training_module_id: '', source_module_title: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zur Serialisierung in die Planungs-API (persistente Modul-Herkunft). */
|
||||||
|
function parseOptionalSourceTrainingModuleIdForPayload(v) {
|
||||||
|
if (v === null || v === undefined || v === '') return null
|
||||||
|
const n = typeof v === 'number' ? v : parseInt(String(v).trim(), 10)
|
||||||
|
return Number.isFinite(n) && n >= 1 ? n : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeUnitToForm(fullUnit) {
|
export function normalizeUnitToForm(fullUnit) {
|
||||||
|
|
@ -79,8 +88,24 @@ export function normalizeUnitToForm(fullUnit) {
|
||||||
guidance_notes: sec.guidance_notes || '',
|
guidance_notes: sec.guidance_notes || '',
|
||||||
items: (sec.items || []).map((it) => {
|
items: (sec.items || []).map((it) => {
|
||||||
if (it.item_type === 'note') {
|
if (it.item_type === 'note') {
|
||||||
return { item_type: 'note', note_body: it.note_body || '' }
|
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||||
|
const rowNote = {
|
||||||
|
item_type: 'note',
|
||||||
|
note_body: it.note_body || '',
|
||||||
|
source_training_module_id: '',
|
||||||
|
source_module_title: '',
|
||||||
|
}
|
||||||
|
if (sm != null) {
|
||||||
|
rowNote.source_training_module_id = sm
|
||||||
|
rowNote.source_module_title = (
|
||||||
|
it.source_module_title ||
|
||||||
|
it.source_training_module_title ||
|
||||||
|
''
|
||||||
|
).trim()
|
||||||
|
}
|
||||||
|
return rowNote
|
||||||
}
|
}
|
||||||
|
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||||
return {
|
return {
|
||||||
item_type: 'exercise',
|
item_type: 'exercise',
|
||||||
exercise_id: it.exercise_id,
|
exercise_id: it.exercise_id,
|
||||||
|
|
@ -97,6 +122,16 @@ export function normalizeUnitToForm(fullUnit) {
|
||||||
: '',
|
: '',
|
||||||
notes: it.notes ?? '',
|
notes: it.notes ?? '',
|
||||||
modifications: it.modifications ?? '',
|
modifications: it.modifications ?? '',
|
||||||
|
...(smEx != null
|
||||||
|
? {
|
||||||
|
source_training_module_id: smEx,
|
||||||
|
source_module_title: (
|
||||||
|
it.source_module_title ||
|
||||||
|
it.source_training_module_title ||
|
||||||
|
''
|
||||||
|
).trim(),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
@ -199,17 +234,21 @@ export function buildSectionsPayload(sections) {
|
||||||
items: (sec.items || [])
|
items: (sec.items || [])
|
||||||
.map((it, ii) => {
|
.map((it, ii) => {
|
||||||
if (it.item_type === 'note') {
|
if (it.item_type === 'note') {
|
||||||
return {
|
const sm = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||||
|
const row = {
|
||||||
item_type: 'note',
|
item_type: 'note',
|
||||||
order_index: ii,
|
order_index: ii,
|
||||||
note_body: it.note_body ?? '',
|
note_body: it.note_body ?? '',
|
||||||
}
|
}
|
||||||
|
if (sm != null) row.source_training_module_id = sm
|
||||||
|
return row
|
||||||
}
|
}
|
||||||
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
|
if (it.exercise_id === '' || it.exercise_id == null || Number.isNaN(Number(it.exercise_id))) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const vid = it.exercise_variant_id
|
const vid = it.exercise_variant_id
|
||||||
return {
|
const smEx = parseOptionalSourceTrainingModuleIdForPayload(it.source_training_module_id)
|
||||||
|
const rowEx = {
|
||||||
item_type: 'exercise',
|
item_type: 'exercise',
|
||||||
order_index: ii,
|
order_index: ii,
|
||||||
exercise_id: parseInt(it.exercise_id, 10),
|
exercise_id: parseInt(it.exercise_id, 10),
|
||||||
|
|
@ -220,11 +259,80 @@ export function buildSectionsPayload(sections) {
|
||||||
notes: it.notes?.trim() ? it.notes.trim() : null,
|
notes: it.notes?.trim() ? it.notes.trim() : null,
|
||||||
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
|
modifications: it.modifications?.trim() ? it.modifications.trim() : null,
|
||||||
}
|
}
|
||||||
|
if (smEx != null) rowEx.source_training_module_id = smEx
|
||||||
|
return rowEx
|
||||||
})
|
})
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
|
||||||
|
export async function insertTrainingModuleIntoPlanningSections({
|
||||||
|
sections,
|
||||||
|
moduleDetail,
|
||||||
|
sectionIndex,
|
||||||
|
insertBeforeItemIndex,
|
||||||
|
}) {
|
||||||
|
const secIx = typeof sectionIndex === 'number' ? sectionIndex : parseInt(String(sectionIndex), 10)
|
||||||
|
if (
|
||||||
|
!Array.isArray(sections) ||
|
||||||
|
!Number.isFinite(secIx) ||
|
||||||
|
secIx < 0 ||
|
||||||
|
secIx >= sections.length ||
|
||||||
|
!moduleDetail ||
|
||||||
|
typeof moduleDetail !== 'object'
|
||||||
|
) {
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
const prev = [...(sections[secIx].items || [])]
|
||||||
|
let beforeIx
|
||||||
|
if (insertBeforeItemIndex === null || insertBeforeItemIndex === undefined || insertBeforeItemIndex === 'end') {
|
||||||
|
beforeIx = prev.length
|
||||||
|
} else if (insertBeforeItemIndex === 'start') {
|
||||||
|
beforeIx = 0
|
||||||
|
} else {
|
||||||
|
const n = typeof insertBeforeItemIndex === 'number' ? insertBeforeItemIndex : parseInt(String(insertBeforeItemIndex), 10)
|
||||||
|
beforeIx = Number.isFinite(n) ? Math.min(Math.max(n, 0), prev.length) : prev.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const midRaw = moduleDetail.id
|
||||||
|
const midNum = typeof midRaw === 'number' ? midRaw : parseInt(String(midRaw), 10)
|
||||||
|
if (!Number.isFinite(midNum) || midNum < 1) return sections
|
||||||
|
|
||||||
|
const modTitle = (moduleDetail.title || '').trim() || `Modul #${midNum}`
|
||||||
|
const modItems = [...(moduleDetail.items || [])].sort(
|
||||||
|
(a, b) => (a.order_index ?? 0) - (b.order_index ?? 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
const appendRows = []
|
||||||
|
for (const mi of modItems) {
|
||||||
|
if (mi.item_type === 'note') {
|
||||||
|
appendRows.push({
|
||||||
|
item_type: 'note',
|
||||||
|
note_body: mi.note_body || '',
|
||||||
|
source_training_module_id: midNum,
|
||||||
|
source_module_title: modTitle,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!mi.exercise_id) continue
|
||||||
|
const hydrated = await hydrateExercisePlanningRow({ id: mi.exercise_id })
|
||||||
|
if (!hydrated) continue
|
||||||
|
hydrated.source_training_module_id = midNum
|
||||||
|
hydrated.source_module_title = modTitle
|
||||||
|
if (mi.exercise_variant_id) hydrated.exercise_variant_id = String(mi.exercise_variant_id)
|
||||||
|
hydrated.planned_duration_min =
|
||||||
|
mi.planned_duration_min !== null && mi.planned_duration_min !== undefined
|
||||||
|
? String(mi.planned_duration_min)
|
||||||
|
: ''
|
||||||
|
hydrated.notes = mi.notes ?? ''
|
||||||
|
appendRows.push(hydrated)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedItems = [...prev.slice(0, beforeIx), ...appendRows, ...prev.slice(beforeIx)]
|
||||||
|
return sections.map((sec, idx) => (idx === secIx ? { ...sec, items: mergedItems } : sec))
|
||||||
|
}
|
||||||
|
|
||||||
export function sectionPlannedMinutes(sec) {
|
export function sectionPlannedMinutes(sec) {
|
||||||
return (sec.items || []).reduce((sum, it) => {
|
return (sec.items || []).reduce((sum, it) => {
|
||||||
if (it.item_type !== 'exercise') return sum
|
if (it.item_type !== 'exercise') return sum
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user