diff --git a/frontend/src/app.css b/frontend/src/app.css index 9f93655..72415e5 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5116,6 +5116,7 @@ a.analysis-split__nav-item { } .tu-planning-module-band { + /* Legacy — Ersetzt durch .tu-module-bundle-head; wird aus Kompatibilität vorerst beibehalten. */ margin-top: 0.85rem; margin-bottom: 0.05rem; padding: 0.35rem 0.65rem; @@ -5128,6 +5129,180 @@ a.analysis-split__nav-item { letter-spacing: 0.01em; } +.tu-module-bundle-head { + display: flex; + align-items: stretch; + gap: 0; + margin: 0.65rem 0 0.15rem; + border-radius: 14px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border2)); + background: linear-gradient( + 134deg, + color-mix(in srgb, var(--accent-light) 55%, var(--surface)) 0%, + var(--surface) 68% + ); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); +} + +.tu-module-bundle-head__stripe { + width: 5px; + flex-shrink: 0; + background: linear-gradient(180deg, var(--accent) 0%, color-mix(in srgb, var(--accent-dark) 92%, black) 100%); +} + +.tu-module-bundle-head__main { + flex: 1; + min-width: 0; + padding: 0.6rem 0.75rem 0.72rem; +} + +.tu-module-bundle-head__kicker { + display: block; + font-size: 0.65rem; + font-weight: 800; + letter-spacing: 0.07em; + text-transform: uppercase; + color: color-mix(in srgb, var(--accent-dark) 88%, var(--text3)); + margin-bottom: 0.12rem; +} + +.tu-module-bundle-head__title { + display: block; + font-size: 1.02rem; + font-weight: 700; + color: var(--text1); + line-height: 1.25; + margin-bottom: 0.5rem; +} + +.tu-module-bundle-head__list { + margin: 0; + padding-left: 1.15rem; + font-size: 0.84rem; + line-height: 1.48; + color: var(--text2); +} + +.tu-module-bundle-head__list li { + margin-bottom: 0.12rem; +} + +.tu-module-bundle-head__list li::marker { + color: var(--accent-dark); +} + +.tu-module-bundle-head__more, +.tu-module-bundle-head__meta, +.tu-module-bundle-head__empty { + margin: 0.25rem 0 0; + font-size: 0.79rem; + line-height: 1.42; + color: var(--text3); +} + +.tu-module-bundle-head__empty { + margin-top: 0.15rem; +} + +.tu-modulepick-search { + width: 100%; + margin-bottom: 0.55rem; +} + +.tu-modulepick-list { + max-height: 240px; + overflow-y: auto; + padding: 2px; + margin: 0 -2px; + border-radius: 10px; + border: 1px solid var(--border2); + background: color-mix(in srgb, var(--surface2) 22%, var(--surface)); + display: flex; + flex-direction: column; + gap: 4px; +} + +.tu-modulepick-item { + text-align: left; + appearance: none; + margin: 0; + cursor: pointer; + width: 100%; + padding: 0.45rem 0.62rem; + border-radius: 8px; + border: 1px solid transparent; + background: var(--surface); + color: var(--text1); + transition: + border-color 0.12s ease, + box-shadow 0.12s ease; +} + +.tu-modulepick-item:hover { + border-color: color-mix(in srgb, var(--accent) 28%, transparent); +} + +.tu-modulepick-item--active { + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent), + 0 2px 10px rgba(29, 158, 117, 0.08); +} + +.tu-modulepick-item__title { + display: block; + font-size: 0.9rem; + font-weight: 700; +} + +.tu-modulepick-item__meta { + display: block; + margin-top: 0.08rem; + font-size: 0.74rem; + color: var(--text3); + line-height: 1.38; +} + +.tu-modulepick-preview { + margin-top: 0.85rem; + padding: 0.55rem 0.72rem; + border-radius: 10px; + border: 1px dashed var(--border2); + background: color-mix(in srgb, var(--surface2) 38%, transparent); +} + +.tu-modulepick-preview__title { + margin: 0 0 0.45rem; + font-size: 0.74rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text3); +} + +.tu-modulepick-preview__list { + margin: 0; + padding-left: 1.1rem; + font-size: 0.82rem; + color: var(--text2); + line-height: 1.42; +} + +.tu-modulepick-preview__more { + margin: 0.35rem 0 0; + font-size: 0.78rem; + color: var(--text3); +} + +.tu-module-apply-placement-details summary { + cursor: pointer; + font-weight: 600; + font-size: 0.82rem; + color: var(--accent-dark); + margin: 0.25rem 0 0; +} + /* Einfügen zwischen Ablaufzeilen (Übung / Modul / Anmerkung) */ .tu-insert-slot { display: flex; @@ -5186,6 +5361,47 @@ a.analysis-split__nav-item { opacity: 0.92; } +.training-unit-sections-editor .tu-item-row { + border-top: none; + margin-top: 0; +} + +.training-unit-sections-editor .tu-item-row--exercise { + margin-top: 0.58rem; + padding: 0.55rem 0.72rem 0.55rem; + border-radius: 14px; + border: 1px solid color-mix(in srgb, var(--border2) 85%, var(--accent) 15%); + background: linear-gradient(160deg, var(--surface) 0%, color-mix(in srgb, var(--surface2) 35%, var(--surface)) 100%); + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.56) inset, + 0 2px 10px rgba(15, 23, 42, 0.05); +} + +.training-unit-sections-editor .tu-item-row--note:not(.tu-item-row--separator-note) { + margin-top: 0.52rem; + padding: 0.48rem 0.72rem; + border-radius: 11px; + border: 1px solid color-mix(in srgb, var(--border2) 70%, transparent); + background: color-mix(in srgb, var(--surface2) 28%, var(--surface)); +} + +.training-unit-sections-editor .tu-item-row--separator-note { + margin-top: 0.42rem; + border-radius: 10px; + border: 1px dashed color-mix(in srgb, var(--border2) 75%, transparent); + background: color-mix(in srgb, var(--surface2) 15%, transparent); +} + +.training-unit-sections-editor .tu-item-row--from-module.tu-item-row--exercise, +.training-unit-sections-editor .tu-item-row--from-module.tu-item-row--note:not(.tu-item-row--separator-note) { + border-left: 4px solid var(--accent); + padding-left: 0.6rem; + box-shadow: + 0 1px 0 rgba(255, 255, 255, 0.52) inset, + 0 2px 12px rgba(29, 158, 117, 0.07), + inset 10px 0 0 color-mix(in srgb, var(--accent) 14%, transparent); +} + .tu-item-row { display: flex; flex-wrap: wrap; diff --git a/frontend/src/components/TrainingUnitSectionsEditor.jsx b/frontend/src/components/TrainingUnitSectionsEditor.jsx index fee6313..d12002d 100644 --- a/frontend/src/components/TrainingUnitSectionsEditor.jsx +++ b/frontend/src/components/TrainingUnitSectionsEditor.jsx @@ -32,6 +32,63 @@ function truncatePreview(text, max = 160) { return `${t.slice(0, max - 1)}…` } +/** Liest den zusammenhängenden Lauf eines Moduls im Abschnitt (ab erstem Item mit dieser Herkunfts-ID). */ +function gatherPlanningModuleOutline(items, startIdx, moduleId) { + const exercises = [] + let notes = 0 + for (let j = startIdx; j < (items?.length ?? 0); j++) { + const row = items[j] + if (normalizedPlanningModuleChainId(row.source_training_module_id) !== moduleId) break + if (row.item_type === 'note') { + const bod = (row.note_body || '').trim() + if (bod === SECTION_INSERT_SEPARATOR_BODY) continue + notes += 1 + continue + } + const t = + (row.exercise_title || '').trim() || + (row.exercise_id ? `Übung #${row.exercise_id}` : 'Übung') + exercises.push(t) + } + return { exercises, notes } +} + +const MODULE_OUTLINE_PREVIEW_MAX = 8 + +function renderModulePlanningHead(modBandTitle, modOutline, showModuleBand) { + if (!showModuleBand || !modOutline) return null + return ( +
+
+
+ Aus Modul + {modBandTitle} + {modOutline.exercises.length === 0 && modOutline.notes === 0 ? ( +

Ohne strukturierten Inhalt angezeigt.

+ ) : ( +
    + {modOutline.exercises.slice(0, MODULE_OUTLINE_PREVIEW_MAX).map((tx, ox) => ( +
  1. {tx}
  2. + ))} +
+ )} + {modOutline.exercises.length > MODULE_OUTLINE_PREVIEW_MAX ? ( +

+ … und noch {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX}{' '} + {modOutline.exercises.length - MODULE_OUTLINE_PREVIEW_MAX === 1 ? 'Übung' : 'Übungen'} +

+ ) : null} + {modOutline.notes > 0 ? ( +

+ sowie {modOutline.notes}{' '} + {modOutline.notes === 1 ? 'Zwischen-Hinweis' : 'Zwischen-Hinweise'} +

+ ) : null} +
+
+ ) +} + function reorderBlocksImmutable(blocks, fromI, toBeforeIdx) { const b = [...blocks] if (fromI < 0 || fromI >= b.length) return blocks @@ -598,25 +655,22 @@ export default function TrainingUnitSectionsEditor({ (it.source_module_title || '').trim() || (curMn != null ? `Modul #${curMn}` : '') + const modOutline = + showModuleBand && curMn != null ? gatherPlanningModuleOutline(sec.items, iIdx, curMn) : null + const fromModClass = curMn != null ? ' tu-item-row--from-module' : '' + if (it.item_type === 'note') { const isSepLine = (it.note_body || '').trim() === SECTION_INSERT_SEPARATOR_BODY const notePv = truncatePreview(it.note_body || '', 260) const noteHasText = Boolean((it.note_body || '').trim()) && !isSepLine return ( - {showModuleBand ? ( -
- Baustein: {modBandTitle} -
- ) : null} + {renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)}
@@ -714,16 +768,8 @@ export default function TrainingUnitSectionsEditor({ return ( - {showModuleBand ? ( -
- Baustein: {modBandTitle} -
- ) : null} -
+ {renderModulePlanningHead(modBandTitle, modOutline, showModuleBand)} +
{enableItemDragReorder ? ( { + const q = moduleApplySearchQuery.trim().toLowerCase().replace(/\s+/g, ' ') + const words = q ? q.split(' ').filter(Boolean) : [] + const list = Array.isArray(moduleApplyList) ? moduleApplyList : [] + if (!words.length) return list + return list.filter((m) => { + const blob = [ + m.title, + m.summary, + m.goal, + m.target_group_notes, + m.deployment_context_notes, + ] + .map((x) => String(x ?? '').toLowerCase()) + .join('\n') + return words.every((w) => blob.includes(w)) + }) + }, [moduleApplySearchQuery, moduleApplyList]) + + const modulePlacementSummary = useMemo(() => { + const secs = Array.isArray(formData.sections) ? formData.sections : [] + let si = + typeof moduleApplySectionIx === 'number' + ? moduleApplySectionIx + : parseInt(String(moduleApplySectionIx), 10) + if (!Number.isFinite(si)) si = 0 + si = Math.max(0, Math.min(si, secs.length ? secs.length - 1 : 0)) + const cap = secs[si]?.items?.length ?? 0 + let beforeIx = cap + if (typeof moduleApplyInsertSlot === 'string' && moduleApplyInsertSlot.startsWith('before:')) { + const zi = parseInt(moduleApplyInsertSlot.slice('before:'.length), 10) + if (Number.isFinite(zi)) beforeIx = Math.min(Math.max(0, zi), cap) + } + const rawTitle = (secs[si]?.title || '').trim() + const secTitle = rawTitle || `Abschnitt ${si + 1}` + let positionDescription + if (cap <= 0) positionDescription = 'als erste Einträge dieses Abschnitts' + else if (beforeIx <= 0) positionDescription = 'vor dem ersten Eintrag dieses Abschnitts' + else if (beforeIx >= cap) positionDescription = 'nach dem letzten Eintrag dieses Abschnitts' + else positionDescription = `unmittelbar vor Eintrag ${beforeIx + 1} (${cap} Einträge im Abschnitt)` + return { secTitle, positionDescription } + }, [formData.sections, moduleApplySectionIx, moduleApplyInsertSlot]) + + useEffect(() => { + if (!moduleApplyOpen || !moduleApplyFilteredList.length) return + if (moduleApplyFilteredList.some((m) => String(m.id) === String(moduleApplyModuleId))) return + setModuleApplyModuleId(String(moduleApplyFilteredList[0].id)) + }, [moduleApplyOpen, moduleApplyFilteredList, moduleApplyModuleId]) + const planningModalClubId = useMemo(() => { const gid = Number(formData.group_id) if (!Number.isFinite(gid) || gid < 1) return null @@ -686,6 +753,12 @@ function TrainingPlanningPage() { const openModuleApplyModal = useCallback(async (placement) => { setModuleApplyErr('') + setModuleApplySearchQuery('') + const placementLocked = + placement != null && + typeof placement.sectionIndex === 'number' && + typeof placement.insertBeforeIndex === 'number' + setModuleApplyPlacementLocked(placementLocked) const secs = planningFormRef.current?.sections ?? [] let secIx = 0 let before = 0 @@ -754,6 +827,7 @@ function TrainingPlanningPage() { nextSections = await enrichSectionsWithVariants(nextSections) setFormData((fd) => ({ ...fd, sections: nextSections })) setModuleApplyOpen(false) + setModuleApplyPlacementLocked(false) } catch (e) { setModuleApplyErr(e.message || 'Einfügen fehlgeschlagen') } finally { @@ -761,6 +835,98 @@ function TrainingPlanningPage() { } }, [moduleApplyModuleId, moduleApplySectionIx, moduleApplyInsertSlot]) + useEffect(() => { + if (!moduleApplyOpen) { + setModulePickPreview({ + loading: false, + moduleId: '', + exercises: [], + notes: 0, + err: '', + }) + return undefined + } + const mid = parseInt(String(moduleApplyModuleId), 10) + if (!Number.isFinite(mid) || mid < 1) { + setModulePickPreview({ + loading: false, + moduleId: '', + exercises: [], + notes: 0, + err: '', + }) + return undefined + } + let cancelled = false + setModulePickPreview({ + loading: true, + moduleId: String(mid), + exercises: [], + notes: 0, + err: '', + }) + ;(async () => { + try { + const detail = await api.getTrainingModule(mid) + if (cancelled) return + const itemsSorted = [...(detail.items ?? [])].sort( + (a, b) => (a.order_index ?? 0) - (b.order_index ?? 0) + ) + const uniqueEx = new Set() + let notes = 0 + for (const row of itemsSorted) { + if ((row.item_type || '') !== 'note') { + const eid = row.exercise_id + if (eid) uniqueEx.add(Number(eid)) + continue + } + const b = String(row.note_body ?? '').trim() + if (b === '---') continue + notes += 1 + } + const titleById = new Map() + await Promise.all( + [...uniqueEx].map(async (eid) => { + try { + const ex = await api.getExercise(eid) + titleById.set(eid, (ex?.title || '').trim() || `Übung #${eid}`) + } catch { + titleById.set(eid, `Übung #${eid}`) + } + }) + ) + if (cancelled) return + const exTitlesInOrder = [] + for (const row of itemsSorted) { + if ((row.item_type || '') !== 'exercise') continue + const eid = Number(row.exercise_id) + if (!Number.isFinite(eid)) continue + exTitlesInOrder.push(titleById.get(eid) || `Übung #${eid}`) + } + setModulePickPreview({ + loading: false, + moduleId: String(mid), + exercises: exTitlesInOrder, + notes, + err: '', + }) + } catch (e) { + if (!cancelled) { + setModulePickPreview({ + loading: false, + moduleId: String(mid), + exercises: [], + notes: 0, + err: e?.message || 'Vorschau fehlgeschlagen', + }) + } + } + })() + return () => { + cancelled = true + } + }, [moduleApplyOpen, moduleApplyModuleId]) + const handleTakeLead = async (unit) => { if (!user?.id) return try { @@ -1919,13 +2085,17 @@ function TrainingPlanningPage() { overflowY: 'auto', }} role="presentation" - onMouseDown={(ev) => ev.target === ev.currentTarget && !moduleApplyBusy && setModuleApplyOpen(false)} + onMouseDown={(ev) => { + if (ev.target !== ev.currentTarget || moduleApplyBusy) return + setModuleApplyOpen(false) + setModuleApplyPlacementLocked(false) + }} >

- Modul einfügen + Trainingsmodul einfügen

-

- Übungen und Notizen des Moduls werden kopiert wie bei einer einzelnen Übung — - ohne die Einheit vorher gespeichert zu haben (Speichern am Ende wie gewohnt). Die Herkunft bleibt - am Block sichtbar; du kannst alles weiter anpassen. +

+ Alle Positionen des gewählten Moduls werden als neue Zeilen eingefügt (Kopie, mit klarer + Herkunft im Ablauf). Die Einheit brauchst du dafür nicht vorher gespeichert zu haben — Speichern am Ende + wie gewohnt. Vollständige Textsuche oder Modulkategorien planen wir serverseitig für + eine spätere Iteration; vorerst steht hier eine{' '} + Schnellsuche über Titel und Freitext-Felder zur Verfügung.

{moduleApplyErr ? (

{moduleApplyErr}

) : null} -
- - -
+ {moduleApplyPlacementLocked ? ( + <> +

+ Aktuelle Einfügeposition: Abschnitt {modulePlacementSummary.secTitle}{' '} + / {modulePlacementSummary.positionDescription} +

+
+ Abschnitt oder Position ändern +
+
+ + +
+
+ + +
+
+
+ + ) : ( + <> +
+ + +
-
- - -
- -
- - setModuleApplyInsertSlot(e.target.value)} + disabled={moduleApplyBusy || !(formData.sections?.length > 0)} + > + - ) - })} - + + {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 ( + + ) + })} + +
+ + )} + +
+ + setModuleApplySearchQuery(e.target.value)} + disabled={moduleApplyBusy} + aria-label="Module durch Freitext filtern" + />
+
+ +
+
+ {!moduleApplyFilteredList.length ? ( +

+ {!moduleApplyList.length ? 'Keine Module verfügbar oder keine Berechtigung.' : 'Kein Modul entspricht der Suche.'} +

+ ) : ( + moduleApplyFilteredList.map((m) => { + const title = ((m.title || '').trim() || `Modul #${m.id}`).trim() + const visLbl = trainingVisibilityShortDE(m.visibility) + const nPos = typeof m.items_count === 'number' ? m.items_count : '—' + const selected = String(m.id) === String(moduleApplyModuleId) + return ( + + ) + }) + )} +
+ + {moduleApplyModuleId ? ( +
+
Ablauf-Vorschau (Bibliotheksmodul)
+ {modulePickPreview.loading ? ( +

+ Übungen und Hinweise laden … +

+ ) : modulePickPreview.err ? ( +

+ {modulePickPreview.err} +

+ ) : !modulePickPreview.exercises.length && !modulePickPreview.notes ? ( +

+ Keine Übungspositionen in diesem Eintrag gefunden (prüfen, ob Übungen im Modul gültige IDs haben). +

+ ) : ( + <> +
    + {(modulePickPreview.exercises.slice(0, 12)).map((t, qi) => ( +
  1. {t}
  2. + ))} +
+ {modulePickPreview.exercises.length > 12 ? ( +

+ … und noch {modulePickPreview.exercises.length - 12} weitere Übungen in genau dieser Modulreihenfolge. +

+ ) : null} + {modulePickPreview.notes > 0 ? ( +

+ zusätzlich {modulePickPreview.notes}{' '} + {modulePickPreview.notes === 1 ? 'Position mit Hinweis' : 'Positionen mit Hinweisen'}{' '} + (ohne Aufzählung) +

+ ) : null} + + )} +
+ ) : null} +
!moduleApplyBusy && setModuleApplyOpen(false)} + onClick={() => { + if (moduleApplyBusy) return + setModuleApplyOpen(false) + setModuleApplyPlacementLocked(false) + }} > Abbrechen