- Introduced new API endpoints for managing exercise media, including upload, update, delete, and reorder functionalities. - Updated the exercise creation and update logic to ensure goal and execution fields are validated and normalized. - Refactored frontend components to support the new exercise media features, including a dedicated import section for complete stack files. - Removed the deprecated ExercisesPage component and replaced it with a more modular structure for exercise management. - Incremented database schema version to 20260427028 and updated changelog to reflect these changes.
510 lines
19 KiB
JavaScript
510 lines
19 KiB
JavaScript
import React, { useEffect, useMemo, useState } from 'react'
|
|
import api from '../../utils/api'
|
|
|
|
function downloadJson(obj, filename) {
|
|
const blob = new Blob([JSON.stringify(obj, null, 2)], {
|
|
type: 'application/json;charset=utf-8'
|
|
})
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = filename
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
function groupModelSkills(model) {
|
|
if (!model?.model_skills?.length) return []
|
|
const groups = new Map()
|
|
for (const ms of model.model_skills) {
|
|
const main = ms.skill_main_category_name || '—'
|
|
const sub = ms.skill_subcategory_name || '—'
|
|
const k = `${main}\t${sub}`
|
|
if (!groups.has(k)) groups.set(k, { main, sub, skills: [] })
|
|
groups.get(k).skills.push(ms)
|
|
}
|
|
return Array.from(groups.values())
|
|
}
|
|
|
|
function cellMapForSkill(model, skillId) {
|
|
const m = {}
|
|
for (const sl of model.skill_levels || []) {
|
|
if (Number(sl.skill_id) === Number(skillId)) {
|
|
m[sl.level_number] = sl
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
export default function MaturityMatrixToolsAdmin() {
|
|
const [focusAreas, setFocusAreas] = useState([])
|
|
const [styles, setStyles] = useState([])
|
|
const [trainingTypes, setTrainingTypes] = useState([])
|
|
const [models, setModels] = useState([])
|
|
const [focusId, setFocusId] = useState('')
|
|
const [styleId, setStyleId] = useState('')
|
|
const [typeId, setTypeId] = useState('')
|
|
const [matrix, setMatrix] = useState(null)
|
|
const [loading, setLoading] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [message, setMessage] = useState('')
|
|
const [importMode, setImportMode] = useState('create')
|
|
const [replaceModelId, setReplaceModelId] = useState('')
|
|
const [exportModelId, setExportModelId] = useState('')
|
|
const [importBindings, setImportBindings] = useState(true)
|
|
const [stackWipe, setStackWipe] = useState(false)
|
|
const [stackConfirmText, setStackConfirmText] = useState('')
|
|
const [stackLoading, setStackLoading] = useState(false)
|
|
const [stackImportLoading, setStackImportLoading] = useState(false)
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
;(async () => {
|
|
try {
|
|
const [fa, sd, tt, m] = await Promise.all([
|
|
api.listFocusAreas({}),
|
|
api.listStyleDirections({}),
|
|
api.listTrainingTypes({}),
|
|
api.listMaturityModels({})
|
|
])
|
|
if (!cancelled) {
|
|
setFocusAreas(fa)
|
|
setStyles(sd)
|
|
setTrainingTypes(tt)
|
|
setModels(m)
|
|
}
|
|
} catch (e) {
|
|
if (!cancelled) setError(e.message || String(e))
|
|
}
|
|
})()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
const groups = useMemo(() => (matrix ? groupModelSkills(matrix) : []), [matrix])
|
|
const levels = matrix?.levels || []
|
|
|
|
async function handleResolve() {
|
|
if (!focusId) {
|
|
setError('Fokusbereich wählen.')
|
|
return
|
|
}
|
|
setLoading(true)
|
|
setError('')
|
|
setMessage('')
|
|
setMatrix(null)
|
|
try {
|
|
const params = { focus_area_id: focusId }
|
|
if (styleId) params.style_direction_id = styleId
|
|
if (typeId) params.training_type_id = typeId
|
|
const m = await api.resolveMaturityModel(params)
|
|
setMatrix(m)
|
|
if (!m) setError('Keine Matrix für diesen Kontext.')
|
|
else setMessage('Matrix geladen.')
|
|
} catch (e) {
|
|
setError(e.message || String(e))
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
async function handleExportResolved() {
|
|
if (!focusId) {
|
|
setError('Fokusbereich wählen.')
|
|
return
|
|
}
|
|
setError('')
|
|
setMessage('')
|
|
try {
|
|
const params = { focus_area_id: focusId }
|
|
if (styleId) params.style_direction_id = styleId
|
|
if (typeId) params.training_type_id = typeId
|
|
const bundle = await api.exportResolvedMaturityBundle(params)
|
|
downloadJson(bundle, 'faehigkeitsmatrix-aufgeloest.json')
|
|
setMessage('Export gestartet (Download).')
|
|
} catch (e) {
|
|
setError(e.message || String(e))
|
|
}
|
|
}
|
|
|
|
async function handleExportStored() {
|
|
if (!exportModelId) {
|
|
setError('Modell für Export auswählen.')
|
|
return
|
|
}
|
|
setError('')
|
|
setMessage('')
|
|
try {
|
|
const bundle = await api.exportMaturityModelBundle(parseInt(exportModelId, 10))
|
|
downloadJson(bundle, `reifegradmodell-${exportModelId}.json`)
|
|
setMessage('Export gestartet (Download).')
|
|
} catch (e) {
|
|
setError(e.message || String(e))
|
|
}
|
|
}
|
|
|
|
async function handleExportStack() {
|
|
setError('')
|
|
setMessage('')
|
|
setStackLoading(true)
|
|
try {
|
|
const bundle = await api.exportMatrixStackBundle()
|
|
const name = `matrix-stack-${(bundle.bundle_export_id || 'export').slice(0, 8)}.json`
|
|
downloadJson(bundle, name)
|
|
setMessage('Komplett-Stack exportiert (Download).')
|
|
} catch (err) {
|
|
setError(err.message || String(err))
|
|
} finally {
|
|
setStackLoading(false)
|
|
}
|
|
}
|
|
|
|
async function handleImportStackFile(e) {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
setError('')
|
|
setMessage('')
|
|
setStackImportLoading(true)
|
|
try {
|
|
const data = JSON.parse(await file.text())
|
|
if (data.kind !== 'shinkan.matrix_stack.v1') {
|
|
setError('Erwartet wird eine Datei mit kind: shinkan.matrix_stack.v1 (Komplett-Stack-Export).')
|
|
e.target.value = ''
|
|
return
|
|
}
|
|
if (stackWipe && stackConfirmText !== 'DELETE_MATURITY_STACK') {
|
|
setError('Vollständiges Ersetzen: Bestätigung exakt „DELETE_MATURITY_STACK“ eintragen (Superadmin).')
|
|
e.target.value = ''
|
|
return
|
|
}
|
|
const payload = {
|
|
...data,
|
|
replace_all_maturity_models: stackWipe,
|
|
confirm_replace_all: stackWipe ? stackConfirmText : undefined
|
|
}
|
|
const res = await api.importMatrixStackBundle(payload)
|
|
const w = res.warnings || []
|
|
setMessage(
|
|
`Stack-Import OK. Modell-Zuordnung: ${Object.keys(res.model_id_map || {}).length} Modell(e), Skills gemappt: ${Object.keys(res.skill_id_map || {}).length}.` +
|
|
(w.length ? ` ${w.length} Hinweis(e) (Konsole).` : '')
|
|
)
|
|
if (w.length) console.warn('matrix_stack import warnings', w)
|
|
setModels(await api.listMaturityModels({}))
|
|
} catch (err) {
|
|
setError(err.message || String(err))
|
|
} finally {
|
|
setStackImportLoading(false)
|
|
e.target.value = ''
|
|
}
|
|
}
|
|
|
|
async function handleImportFile(e) {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
setError('')
|
|
setMessage('')
|
|
try {
|
|
const data = JSON.parse(await file.text())
|
|
if (data.kind === 'shinkan.matrix_stack.v1') {
|
|
setError('Komplett-Stack: bitte die Datei im Abschnitt „Komplett-Stack“ importieren (eigenes Dateifeld).')
|
|
e.target.value = ''
|
|
return
|
|
} else {
|
|
const payload = { ...data, mode: importMode, import_bindings: importBindings }
|
|
if (importMode === 'replace') {
|
|
if (!replaceModelId) {
|
|
setError('Bei „Ersetzen“ die Ziel-Modell-ID angeben.')
|
|
e.target.value = ''
|
|
return
|
|
}
|
|
payload.replace_model_id = parseInt(replaceModelId, 10)
|
|
}
|
|
const res = await api.importMaturityModelBundle(payload)
|
|
setMessage(`Import erfolgreich. Modell-ID: ${res.id}`)
|
|
setModels(await api.listMaturityModels({}))
|
|
}
|
|
} catch (err) {
|
|
setError(err.message || String(err))
|
|
} finally {
|
|
e.target.value = ''
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="admin-matrix-tools">
|
|
<p className="admin-matrix-tools__intro muted">
|
|
Matrix nach Kontext auflösen, hierarchisch nach Hauptkategorie und Kategorie darstellen, sowie JSON
|
|
exportieren oder importieren (gespeichertes Modell inkl. optional Kontext-Bindings, aufgelöste Matrix, oder{' '}
|
|
<strong>Komplett-Stack</strong> mit Fähigkeitskatalog und allen Reifegradmodellen für Test → Prod).
|
|
</p>
|
|
|
|
{error ? (
|
|
<div className="skills-catalog-admin__error" role="alert">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
{message ? <p className="muted admin-matrix-tools__msg">{message}</p> : null}
|
|
|
|
<section className="card admin-matrix-tools__section">
|
|
<h2 className="admin-matrix-tools__h2">Kontext und Anzeige</h2>
|
|
<div className="admin-matrix-tools__filters">
|
|
<div>
|
|
<label className="form-label">Fokusbereich</label>
|
|
<select
|
|
className="form-input"
|
|
value={focusId}
|
|
onChange={(e) => setFocusId(e.target.value)}
|
|
>
|
|
<option value="">— wählen —</option>
|
|
{focusAreas.map((f) => (
|
|
<option key={f.id} value={f.id}>
|
|
{f.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="form-label">Stilrichtung (optional)</label>
|
|
<select
|
|
className="form-input"
|
|
value={styleId}
|
|
onChange={(e) => setStyleId(e.target.value)}
|
|
>
|
|
<option value="">— alle / egal —</option>
|
|
{styles.map((s) => (
|
|
<option key={s.id} value={s.id}>
|
|
{s.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="form-label">Trainingsstil (optional)</label>
|
|
<select className="form-input" value={typeId} onChange={(e) => setTypeId(e.target.value)}>
|
|
<option value="">— alle / egal —</option>
|
|
{trainingTypes.map((t) => (
|
|
<option key={t.id} value={t.id}>
|
|
{t.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="admin-matrix-tools__actions">
|
|
<button type="button" className="btn btn-primary" disabled={loading} onClick={handleResolve}>
|
|
{loading ? 'Lade…' : 'Matrix auflösen & anzeigen'}
|
|
</button>
|
|
<button type="button" className="btn btn-secondary" onClick={handleExportResolved}>
|
|
Aufgelöste Matrix exportieren (JSON)
|
|
</button>
|
|
</div>
|
|
{matrix?.resolution ? (
|
|
<p className="muted admin-matrix-tools__meta">
|
|
Quelle:{' '}
|
|
{matrix.resolution.merged
|
|
? `zusammengeführt aus Modell-IDs ${(matrix.resolution.source_model_ids || []).join(', ')}`
|
|
: `Modell-ID ${matrix.id}`}
|
|
</p>
|
|
) : null}
|
|
</section>
|
|
|
|
{matrix ? (
|
|
<section className="card admin-matrix-tools__section admin-matrix-tools__visual">
|
|
<h2 className="admin-matrix-tools__h2">
|
|
{matrix.name}
|
|
<span className="muted admin-matrix-tools__subtitle">
|
|
{' '}
|
|
· {matrix.level_count} Stufen · {matrix.model_skills?.length || 0} Fähigkeiten
|
|
</span>
|
|
</h2>
|
|
{groups.map((g) => (
|
|
<div key={`${g.main}-${g.sub}`} className="admin-matrix-visual-group">
|
|
<h3 className="admin-matrix-visual-group__main">{g.main}</h3>
|
|
<h4 className="admin-matrix-visual-group__sub">{g.sub}</h4>
|
|
<div className="admin-matrix-visual-table-wrap">
|
|
<table className="admin-matrix-visual-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="admin-matrix-visual-table__skill">Fähigkeit</th>
|
|
{levels.map((lv) => (
|
|
<th key={lv.level_number} className="admin-matrix-visual-table__level">
|
|
<span className="admin-matrix-visual-table__ln">{lv.level_number}</span>
|
|
<span className="admin-matrix-visual-table__lname">{lv.name}</span>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{g.skills.map((ms) => {
|
|
const cells = cellMapForSkill(matrix, ms.skill_id)
|
|
return (
|
|
<tr key={ms.skill_id}>
|
|
<td className="admin-matrix-visual-table__skill-cell">{ms.skill_name}</td>
|
|
{levels.map((lv) => {
|
|
const c = cells[lv.level_number]
|
|
return (
|
|
<td key={lv.level_number} className="admin-matrix-visual-table__cell">
|
|
{c ? (
|
|
<>
|
|
<div className="admin-matrix-visual-table__goal">
|
|
{c.description || '—'}
|
|
</div>
|
|
{c.observable_criteria ? (
|
|
<div className="admin-matrix-visual-table__obs muted">
|
|
<strong>Beobachtung:</strong> {c.observable_criteria}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<span className="muted">—</span>
|
|
)}
|
|
</td>
|
|
)
|
|
})}
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="card admin-matrix-tools__section">
|
|
<h2 className="admin-matrix-tools__h2">Komplett-Stack (Katalog + Modelle + Bindings)</h2>
|
|
<p className="muted admin-matrix-tools__hint">
|
|
Export enthält <code className="admin-bindings__code">skill_main_categories</code>,{' '}
|
|
<code className="admin-bindings__code">skill_categories</code>, <code className="admin-bindings__code">skills</code>,{' '}
|
|
<code className="admin-bindings__code">skill_level_definitions</code>, alle Reifegradmodelle (Stufen, Matrix-Zellen,
|
|
Legacy-Kontext M:N) sowie <code className="admin-bindings__code">context_bindings</code> mit{' '}
|
|
<strong>Fokus-/Stil-/Trainingsstil-Namen</strong> für die Ziel-DB. Auf Prod müssen dieselben
|
|
Katalognamen für Fokusbereiche, Stilrichtungen und Trainingsstile existieren.
|
|
</p>
|
|
<div className="admin-matrix-tools__actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
disabled={stackLoading || stackImportLoading}
|
|
onClick={handleExportStack}
|
|
>
|
|
{stackLoading ? 'Export…' : 'Komplett-Stack exportieren'}
|
|
</button>
|
|
</div>
|
|
<h3 className="admin-matrix-tools__h3">Komplett-Stack importieren</h3>
|
|
<p className="muted admin-matrix-tools__hint">
|
|
JSON vom Export dieses Dialogs (<code className="admin-bindings__code">shinkan.matrix_stack.v1</code>). Katalog
|
|
wird per Slug zusammengeführt; Skills per Kategorie + Name. Reifegradmodelle werden neu angelegt; Kontext-Bindings
|
|
über Namen der Fokus-/Stil-/Trainingsstil-Kataloge auf der Ziel-DB.
|
|
</p>
|
|
<label className="form-label admin-matrix-tools__check">
|
|
<input
|
|
type="checkbox"
|
|
checked={stackWipe}
|
|
onChange={(e) => {
|
|
setStackWipe(e.target.checked)
|
|
if (!e.target.checked) setStackConfirmText('')
|
|
}}
|
|
/>{' '}
|
|
Zuerst alle Reifegradmodelle auf dieser Datenbank löschen (nur Superadmin)
|
|
</label>
|
|
{stackWipe ? (
|
|
<>
|
|
<label className="form-label">Bestätigung (exakt)</label>
|
|
<input
|
|
className="form-input"
|
|
value={stackConfirmText}
|
|
onChange={(e) => setStackConfirmText(e.target.value)}
|
|
placeholder="DELETE_MATURITY_STACK"
|
|
autoComplete="off"
|
|
/>
|
|
</>
|
|
) : null}
|
|
<label className="form-label">Stack-JSON-Datei</label>
|
|
<input
|
|
type="file"
|
|
accept="application/json,.json"
|
|
disabled={stackImportLoading}
|
|
onChange={handleImportStackFile}
|
|
/>
|
|
{stackImportLoading ? <p className="muted admin-matrix-tools__msg">Import läuft…</p> : null}
|
|
</section>
|
|
|
|
<section className="card admin-matrix-tools__section">
|
|
<h2 className="admin-matrix-tools__h2">Export / Import (JSON)</h2>
|
|
<div className="admin-matrix-tools__io-grid">
|
|
<div>
|
|
<h3 className="admin-matrix-tools__h3">Gespeichertes Modell exportieren</h3>
|
|
<p className="muted admin-matrix-tools__hint">
|
|
Enthält Stufen, Fähigkeiten, Zelltexte und die dem Modell zugeordneten Kontext-Bindings.
|
|
</p>
|
|
<label className="form-label">Modell</label>
|
|
<select
|
|
className="form-input"
|
|
value={exportModelId}
|
|
onChange={(e) => setExportModelId(e.target.value)}
|
|
>
|
|
<option value="">— wählen —</option>
|
|
{models.map((m) => (
|
|
<option key={m.id} value={m.id}>
|
|
{m.name} ({m.status})
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary admin-matrix-tools__btn-mt"
|
|
onClick={handleExportStored}
|
|
>
|
|
JSON herunterladen
|
|
</button>
|
|
</div>
|
|
<div>
|
|
<h3 className="admin-matrix-tools__h3">Import</h3>
|
|
<p className="muted admin-matrix-tools__hint">
|
|
<code className="admin-bindings__code">shinkan.maturity_model.v1</code> oder{' '}
|
|
<code className="admin-bindings__code">shinkan.maturity_matrix_resolved.v1</code>. Komplett-Stack bitte im
|
|
Abschnitt <strong>Komplett-Stack</strong> oben importieren. Aufgelöste Matrizen legen ein neues Modell an bzw.
|
|
ersetzen den Inhalt des Zielmodells (ohne Bindings).
|
|
</p>
|
|
<label className="form-label">Modus</label>
|
|
<select
|
|
className="form-input"
|
|
value={importMode}
|
|
onChange={(e) => setImportMode(e.target.value)}
|
|
>
|
|
<option value="create">Neues Modell anlegen</option>
|
|
<option value="replace">Bestehendes Modell ersetzen (Matrix-Inhalt)</option>
|
|
</select>
|
|
{importMode === 'replace' ? (
|
|
<>
|
|
<label className="form-label">Ziel-Modell-ID</label>
|
|
<input
|
|
className="form-input"
|
|
type="number"
|
|
inputMode="numeric"
|
|
value={replaceModelId}
|
|
onChange={(e) => setReplaceModelId(e.target.value)}
|
|
placeholder="z. B. 3"
|
|
/>
|
|
</>
|
|
) : null}
|
|
<label className="form-label admin-matrix-tools__check">
|
|
<input
|
|
type="checkbox"
|
|
checked={importBindings}
|
|
onChange={(e) => setImportBindings(e.target.checked)}
|
|
/>{' '}
|
|
Kontext-Bindings mit importieren (nur bei <code className="admin-bindings__code">.maturity_model.v1</code>)
|
|
</label>
|
|
<label className="form-label">JSON-Datei</label>
|
|
<input type="file" accept="application/json,.json" onChange={handleImportFile} />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
)
|
|
}
|