shinkan-jinkendo/frontend/src/components/admin/MaturityMatrixToolsAdmin.jsx
Lars cb11e39201
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m54s
feat: enhance exercise management and media handling
- 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.
2026-04-27 14:27:25 +02:00

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>
)
}