shinkan-jinkendo/frontend/src/components/CombinationMethodProfileEditor.jsx

414 lines
17 KiB
React
Raw Normal View History

import React, { useMemo, useState } from 'react'
import { archetypeCoachHint, combinationArchetypeLabel, sortCombinationSlotsForDisplay } from '../constants/combinationArchetypes'
import {
METHOD_PROFILE_GUI_FIELDS,
parseProfileJson,
setFullProfileRawJson,
updateProfileGuided,
patchMethodProfile,
readSlotProfilesV1,
patchSlotTimingField,
applyCircuitRotateQuickRatio,
applyIntervalDomainQuickRatio,
} from '../utils/combinationMethodProfileUi'
function clampInt(n, min, max) {
if (!Number.isFinite(n)) return null
let x = n
if (typeof min === 'number' && x < min) x = min
if (typeof max === 'number' && x > max) x = max
return Math.round(x)
}
/** Archetypen mit klar bezifferbarer Stationslogik · alle mit Slot-Liste sinnvoll */
const ARCHETYPES_WITH_SLOT_TIMING = new Set([
'circuit_rotate_time',
'sequence_linear',
'station_parcour',
'time_domain_interval',
'circuit_all_parallel',
'pair_superset',
'free_method_block',
])
/**
* Kombination: geführtes method_profile (+ optional Stationszeilen, ohne JSON für Trainer).
*
* @param {boolean} [props.plannerMode] — z. B. Planungs‑Override: keine Roh‑JSON‑Sektion.
* @param {boolean} [props.allowExpertJson] — wenn true und nicht plannerMode: Roh‑JSON (Support).
* @param {{ slot_index?: number|string, title?: string }[]} [props.comboSlotsOutline] — für Slot‑Felder aus der Übung
*/
export default function CombinationMethodProfileEditor({
methodArchetype,
methodProfileJson,
onChangeMethodProfileJson,
plannerMode = false,
allowExpertJson = false,
comboSlotsOutline = null,
omitPerSlotTiming = false,
}) {
const arch = typeof methodArchetype === 'string' ? methodArchetype.trim() : ''
const fieldsGui = METHOD_PROFILE_GUI_FIELDS[arch]
const fields = Array.isArray(fieldsGui) ? fieldsGui : null
const parseState = useMemo(() => parseProfileJson(methodProfileJson || '{}'), [methodProfileJson])
const [rawOpenError, setRawOpenError] = useState(null)
const [rawDraft, setRawDraft] = useState(null)
const [presetHint, setPresetHint] = useState(null)
const profileObj = parseState.ok ? parseState.obj : {}
const outlineSorted = useMemo(() => {
if (!comboSlotsOutline || !Array.isArray(comboSlotsOutline) || comboSlotsOutline.length === 0) return []
return sortCombinationSlotsForDisplay(comboSlotsOutline)
}, [comboSlotsOutline])
const showSlotTiming =
!omitPerSlotTiming && ARCHETYPES_WITH_SLOT_TIMING.has(arch) && outlineSorted.length > 0
const slotRowsModel = useMemo(() => readSlotProfilesV1(profileObj), [profileObj])
const lookupSlotTiming = (slotIndex) =>
slotRowsModel.find((r) => Number(r.slot_index) === Number(slotIndex)) || {}
const applyGuided = (key, value, kind) => {
if (kind === 'bool') {
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, value, 'bool')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
setPresetHint(null)
return
}
if (value === '' || value === undefined || value === null) {
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, '', 'int')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
setPresetHint(null)
return
}
const num = typeof value === 'number' ? value : parseInt(String(value), 10)
if (!Number.isFinite(num)) return
const def = METHOD_PROFILE_GUI_FIELDS[arch]?.find((f) => f.key === key && f.kind === 'int')
const c = clampInt(num, def?.min, def?.max)
if (c == null) return
const res = updateProfileGuided(arch, methodProfileJson || '{}', key, c, 'int')
if (!res.ok) return
onChangeMethodProfileJson(res.json)
setPresetHint(null)
}
const onSlotField = (slotIx, field, rawStr) => {
const patched = patchMethodProfile(methodProfileJson || '{}', (d) =>
patchSlotTimingField(d, slotIx, field, rawStr)
)
if (!patched.ok) return
onChangeMethodProfileJson(patched.json)
setPresetHint(null)
}
const runCircuitPreset = (presetId) => {
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
const pr = applyCircuitRotateQuickRatio(draft, presetId)
if (!pr.ok) setPresetHint(pr.error || '')
else setPresetHint(null)
})
if (!r.ok) return
onChangeMethodProfileJson(r.json)
}
const runIntervalPreset = (presetId) => {
const r = patchMethodProfile(methodProfileJson || '{}', (draft) => {
const pr = applyIntervalDomainQuickRatio(draft, presetId)
if (!pr.ok) setPresetHint(pr.error || '')
else setPresetHint(null)
})
if (!r.ok) return
onChangeMethodProfileJson(r.json)
}
const archeLabel = arch ? combinationArchetypeLabel(arch) : null
const openAdvanced = () => {
setRawOpenError(null)
const p = parseProfileJson(methodProfileJson || '{}')
setRawDraft(p.ok ? JSON.stringify(p.obj, null, 2) : String(methodProfileJson || ''))
}
const showExpertSection = allowExpertJson && !plannerMode
return (
<div style={{ marginTop: '10px' }}>
{presetHint ? (
<p style={{ fontSize: '12px', color: 'var(--danger)', margin: '0 0 8px', lineHeight: 1.4 }}>{presetHint}</p>
) : null}
{arch ? (
<p style={{ fontSize: '12px', color: 'var(--text2)', lineHeight: 1.48, margin: '0 0 12px' }}>
<strong style={{ color: 'var(--text1)' }}>
Coach &amp; Planung:{' '}
{archeLabel && archeLabel !== arch ? `${archeLabel} · ` : ''}
</strong>
{archetypeCoachHint(arch)}
</p>
) : (
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 12px' }}>
Wähle einen Methoden‑Archetyp — besonders beim <strong>freien Methodenblock</strong> stehen alle
typischen Stations‑Zeiten zur Verfügung. Ohne Archetyp keine geführten Eingaben.
</p>
)}
{!parseState.ok ? (
<p style={{ color: 'var(--danger)', fontSize: '13px', marginBottom: '10px' }}>{parseState.error}</p>
) : null}
{fields && fields.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '12px' }}>
{arch === 'circuit_rotate_time' ? (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '4px',
alignItems: 'center',
}}
>
<span style={{ fontSize: '11px', color: 'var(--text3)', marginRight: '6px' }}>Schnellwahl:</span>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('transition_equals_work')}>
Wechsel ≈ Arbeit
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('round_rest_equals_work')}>
Runden‑Pause ≈ Arbeit
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runCircuitPreset('round_rest_two_thirds_work')}>
Runden‑Pause ≈ ⅔ Arbeit
</button>
</div>
) : null}
{arch === 'time_domain_interval' ? (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '6px',
marginBottom: '4px',
alignItems: 'center',
}}
>
<span style={{ fontSize: '11px', color: 'var(--text3)', marginRight: '6px' }}>Schnellwahl:</span>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runIntervalPreset('rest_equals_work')}>
Erholung = Belastung
</button>
<button type="button" className="btn btn-secondary framework-ctrl framework-ctrl--xs" onClick={() => runIntervalPreset('rest_two_thirds_work')}>
Erholung ≈ ⅔ Belastung
</button>
</div>
) : null}
{fields.map((def) => {
if (def.kind === 'bool') {
const ck = !!profileObj[def.key]
return (
<label
key={def.key}
style={{ display: 'flex', gap: '8px', alignItems: 'center', cursor: 'pointer', fontSize: '0.9rem' }}
>
<input type="checkbox" checked={ck} onChange={(e) => applyGuided(def.key, e.target.checked, 'bool')} />
<span>{def.label}</span>
</label>
)
}
const v = profileObj[def.key]
const str =
v === undefined || v === null ? '' : typeof v === 'number' && Number.isFinite(v) ? String(v) : String(v)
return (
<div className="form-row" key={def.key}>
<label className="form-label" style={{ fontSize: '12px' }}>
{def.label}
</label>
<input
type="number"
className="form-input"
min={def.min}
max={def.max}
value={str}
placeholder="optional"
onChange={(e) => {
const t = e.target.value
if (t.trim() === '') applyGuided(def.key, '', 'int')
else applyGuided(def.key, parseInt(t, 10), 'int')
}}
/>
</div>
)
})}
</div>
) : arch && fields && fields.length === 0 ? (
<div style={{ marginBottom: '12px' }}>
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: '0 0 10px', lineHeight: 1.45 }}>
Dieser Archetyp ist für <strong>maximal flexible</strong> Stationsblöcke gedacht — die Zeit‑Eckdaten sind
unten je Station möglich. Freitexte der Kombination beschreiben alles Organisatorische, was nicht in
Sekunden gefasst wird.
</p>
</div>
) : null}
{showSlotTiming ? (
<div
style={{
marginTop: '6px',
marginBottom: '14px',
padding: '12px 14px',
borderRadius: '10px',
border: '1px solid var(--border)',
background: 'var(--surface)',
}}
>
<div style={{ fontSize: '0.88rem', fontWeight: 700, marginBottom: '10px', color: 'var(--text1)' }}>
Pro Station / Slot (Zeiten in Sekunden)
</div>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.45 }}>
Belastungsdauer, wie oft die Übung an der gleichen Station hintereinander, kurze Pause dazwischen, Zeit bis
zur nächsten Station. Felder können leer bleiben — z. B. nutzt der Zirkel oben erst die globalen Arbeit‑/
Rotations‑Sekunden.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{outlineSorted.map((slot) => {
const siRaw = slot.slot_index
const si =
siRaw === '' || siRaw == null ? null : typeof siRaw === 'number' ? siRaw : parseInt(String(siRaw), 10)
if (!Number.isFinite(si)) return null
const row = lookupSlotTiming(si)
const ttl = ((slot.title || '').trim() || `Station ${si}`).trim()
return (
<div
key={`slot-timing-${si}`}
style={{
padding: '10px',
borderRadius: '10px',
border: '1px solid var(--border)',
background: 'var(--surface2)',
}}
>
<div style={{ marginBottom: '10px', fontWeight: 700, color: 'var(--text1)', fontSize: '0.9rem' }}>
Station {si}
<span style={{ fontWeight: 400, marginLeft: 8, color: 'var(--text2)', fontSize: '0.86rem' }}>{ttl}</span>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: '10px',
alignItems: 'end',
}}
>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Belastung (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder="–"
value={row.load_sec != null ? String(row.load_sec) : ''}
onChange={(e) => onSlotField(si, 'load_sec', e.target.value)}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Wdh. ohne Wechsel
</label>
<input
type="number"
min={1}
className="form-input"
placeholder="oft 1"
value={row.consecutive_reps != null ? String(row.consecutive_reps) : ''}
onChange={(e) => onSlotField(si, 'consecutive_reps', e.target.value)}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Pause zwischen Wdh. (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder="–"
value={row.intra_rep_rest_sec != null ? String(row.intra_rep_rest_sec) : ''}
onChange={(e) => onSlotField(si, 'intra_rep_rest_sec', e.target.value)}
/>
</div>
<div className="form-row" style={{ marginBottom: 0 }}>
<label className="form-label" style={{ fontSize: '11px' }}>
Pause / Wechsel (s)
</label>
<input
type="number"
min={0}
className="form-input"
placeholder="–"
value={row.transition_after_sec != null ? String(row.transition_after_sec) : ''}
onChange={(e) => onSlotField(si, 'transition_after_sec', e.target.value)}
/>
</div>
</div>
</div>
)
})}
</div>
</div>
) : null}
{showExpertSection ? (
<details
style={{
marginTop: '4px',
borderRadius: '8px',
border: '1px solid var(--border)',
padding: '8px 10px',
background: 'var(--surface2)',
}}
onToggle={(ev) => {
if (ev.target.open) openAdvanced()
}}
>
<summary style={{ cursor: 'pointer', fontSize: '0.82rem', fontWeight: 600 }}>
Support / Entwicklung: Rohdaten (JSON)
</summary>
<p style={{ fontSize: '11px', color: 'var(--text3)', margin: '8px 0 6px', lineHeight: 1.4 }}>
Für Migrationen und Sonderfälle. Geführte Felder setzen weiterhin gültige Standardschlüssel.
</p>
<textarea
className="form-input"
rows={8}
style={{ fontFamily: 'Consolas,monospace', fontSize: '12px' }}
value={rawDraft != null ? rawDraft : methodProfileJson || '{}'}
onChange={(e) => {
setRawDraft(e.target.value)
setRawOpenError(null)
}}
spellCheck={false}
onBlur={() => {
const src = rawDraft != null ? rawDraft : methodProfileJson
const res = setFullProfileRawJson(src || '{}')
if (!res.ok) {
setRawOpenError(res.error)
return
}
setRawOpenError(null)
setRawDraft(null)
onChangeMethodProfileJson(res.json)
}}
/>
{rawOpenError ? (
<p style={{ color: 'var(--danger)', fontSize: '12px', marginTop: '6px' }}>{rawOpenError}</p>
) : null}
</details>
) : null}
</div>
)
}