744 lines
28 KiB
React
744 lines
28 KiB
React
|
|
import React, { useEffect, useState } from 'react'
|
|||
|
|
import { Navigate } from 'react-router-dom'
|
|||
|
|
import { useAuth } from '../context/AuthContext'
|
|||
|
|
import api from '../utils/api'
|
|||
|
|
import AdminPageNav from '../components/AdminPageNav'
|
|||
|
|
|
|||
|
|
export default function AdminMaturityModelsPage() {
|
|||
|
|
const { user } = useAuth()
|
|||
|
|
const isAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
|||
|
|
const isSuperadmin = user?.role === 'superadmin'
|
|||
|
|
|
|||
|
|
const [models, setModels] = useState([])
|
|||
|
|
const [selectedId, setSelectedId] = useState(null)
|
|||
|
|
const [detail, setDetail] = useState(null)
|
|||
|
|
const [loading, setLoading] = useState(false)
|
|||
|
|
const [saving, setSaving] = useState(false)
|
|||
|
|
const [error, setError] = useState('')
|
|||
|
|
const [focusAreas, setFocusAreas] = useState([])
|
|||
|
|
const [styles, setStyles] = useState([])
|
|||
|
|
const [targetGroups, setTargetGroups] = useState([])
|
|||
|
|
const [allSkills, setAllSkills] = useState([])
|
|||
|
|
|
|||
|
|
const [newModel, setNewModel] = useState({
|
|||
|
|
name: '',
|
|||
|
|
level_count: 5,
|
|||
|
|
focus_area_id: '',
|
|||
|
|
style_direction_id: '',
|
|||
|
|
target_group_id: '',
|
|||
|
|
status: 'draft'
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const [meta, setMeta] = useState(null)
|
|||
|
|
const [levelCount, setLevelCount] = useState(5)
|
|||
|
|
const [levelsForm, setLevelsForm] = useState([])
|
|||
|
|
const [cellDraft, setCellDraft] = useState({})
|
|||
|
|
const [skillToAdd, setSkillToAdd] = useState('')
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!isAdmin) return
|
|||
|
|
let cancelled = false
|
|||
|
|
;(async () => {
|
|||
|
|
try {
|
|||
|
|
const [m, fa, sd, tg, sk] = await Promise.all([
|
|||
|
|
api.listMaturityModels({}),
|
|||
|
|
api.listFocusAreas({}),
|
|||
|
|
api.listStyleDirections({}),
|
|||
|
|
api.listTargetGroups({}),
|
|||
|
|
api.listSkills({ status: 'active' })
|
|||
|
|
])
|
|||
|
|
if (!cancelled) {
|
|||
|
|
setModels(m)
|
|||
|
|
setFocusAreas(fa)
|
|||
|
|
setStyles(sd)
|
|||
|
|
setTargetGroups(tg)
|
|||
|
|
setAllSkills(sk)
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
if (!cancelled) setError(e.message)
|
|||
|
|
}
|
|||
|
|
})()
|
|||
|
|
return () => { cancelled = true }
|
|||
|
|
}, [isAdmin])
|
|||
|
|
|
|||
|
|
async function refreshModels() {
|
|||
|
|
const m = await api.listMaturityModels({})
|
|||
|
|
setModels(m)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function selectModel(id) {
|
|||
|
|
setSelectedId(id)
|
|||
|
|
setLoading(true)
|
|||
|
|
setError('')
|
|||
|
|
try {
|
|||
|
|
const d = await api.getMaturityModel(id)
|
|||
|
|
setDetail(d)
|
|||
|
|
setMeta({
|
|||
|
|
name: d.name,
|
|||
|
|
description: d.description || '',
|
|||
|
|
focus_area_id: d.focus_area_id ?? '',
|
|||
|
|
style_direction_id: d.style_direction_id ?? '',
|
|||
|
|
target_group_id: d.target_group_id ?? '',
|
|||
|
|
status: d.status,
|
|||
|
|
version: d.version || '1.0'
|
|||
|
|
})
|
|||
|
|
const lc = parseInt(String(d.level_count), 10)
|
|||
|
|
setLevelCount(lc)
|
|||
|
|
setLevelsForm(
|
|||
|
|
(d.levels || []).map((l) => ({
|
|||
|
|
level_number: l.level_number,
|
|||
|
|
name: l.name,
|
|||
|
|
description: l.description || '',
|
|||
|
|
sort_order: l.sort_order
|
|||
|
|
}))
|
|||
|
|
)
|
|||
|
|
const draft = {}
|
|||
|
|
for (const r of d.skill_levels || []) {
|
|||
|
|
draft[`${r.skill_id}-${r.level_number}`] = {
|
|||
|
|
description: r.description || '',
|
|||
|
|
observable_criteria: r.observable_criteria || ''
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
setCellDraft(draft)
|
|||
|
|
} catch (e) {
|
|||
|
|
setError(e.message)
|
|||
|
|
setDetail(null)
|
|||
|
|
setMeta(null)
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleCreate(e) {
|
|||
|
|
e.preventDefault()
|
|||
|
|
setError('')
|
|||
|
|
setSaving(true)
|
|||
|
|
try {
|
|||
|
|
const payload = {
|
|||
|
|
name: newModel.name.trim(),
|
|||
|
|
level_count: parseInt(String(newModel.level_count), 10),
|
|||
|
|
status: newModel.status,
|
|||
|
|
focus_area_id: newModel.focus_area_id ? parseInt(String(newModel.focus_area_id), 10) : null,
|
|||
|
|
style_direction_id: newModel.style_direction_id
|
|||
|
|
? parseInt(String(newModel.style_direction_id), 10)
|
|||
|
|
: null,
|
|||
|
|
target_group_id: newModel.target_group_id
|
|||
|
|
? parseInt(String(newModel.target_group_id), 10)
|
|||
|
|
: null
|
|||
|
|
}
|
|||
|
|
const created = await api.createMaturityModel(payload)
|
|||
|
|
await refreshModels()
|
|||
|
|
await selectModel(created.id)
|
|||
|
|
setNewModel({
|
|||
|
|
name: '',
|
|||
|
|
level_count: 5,
|
|||
|
|
focus_area_id: '',
|
|||
|
|
style_direction_id: '',
|
|||
|
|
target_group_id: '',
|
|||
|
|
status: 'draft'
|
|||
|
|
})
|
|||
|
|
} catch (e) {
|
|||
|
|
setError(e.message)
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleSaveMeta() {
|
|||
|
|
if (!selectedId || !meta) return
|
|||
|
|
setError('')
|
|||
|
|
setSaving(true)
|
|||
|
|
try {
|
|||
|
|
await api.updateMaturityModel(selectedId, {
|
|||
|
|
name: meta.name,
|
|||
|
|
description: meta.description || null,
|
|||
|
|
focus_area_id: meta.focus_area_id === '' ? null : parseInt(String(meta.focus_area_id), 10),
|
|||
|
|
style_direction_id:
|
|||
|
|
meta.style_direction_id === '' ? null : parseInt(String(meta.style_direction_id), 10),
|
|||
|
|
target_group_id:
|
|||
|
|
meta.target_group_id === '' ? null : parseInt(String(meta.target_group_id), 10),
|
|||
|
|
status: meta.status,
|
|||
|
|
version: meta.version
|
|||
|
|
})
|
|||
|
|
await refreshModels()
|
|||
|
|
await selectModel(selectedId)
|
|||
|
|
} catch (e) {
|
|||
|
|
setError(e.message)
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleSaveLevels() {
|
|||
|
|
if (!selectedId || !meta) return
|
|||
|
|
setError('')
|
|||
|
|
setSaving(true)
|
|||
|
|
try {
|
|||
|
|
await api.updateMaturityModel(selectedId, {
|
|||
|
|
level_count: parseInt(String(levelCount), 10),
|
|||
|
|
levels: levelsForm.map((l) => ({
|
|||
|
|
level_number: parseInt(String(l.level_number), 10),
|
|||
|
|
name: l.name,
|
|||
|
|
description: l.description || null,
|
|||
|
|
sort_order: parseInt(String(l.sort_order), 10)
|
|||
|
|
}))
|
|||
|
|
})
|
|||
|
|
await refreshModels()
|
|||
|
|
await selectModel(selectedId)
|
|||
|
|
} catch (e) {
|
|||
|
|
setError(e.message)
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleSaveMatrix() {
|
|||
|
|
if (!selectedId || !detail) return
|
|||
|
|
if (!detail.model_skills?.length) {
|
|||
|
|
setError('Matrix: Zuerst Fähigkeiten hinzufügen.')
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
setError('')
|
|||
|
|
setSaving(true)
|
|||
|
|
try {
|
|||
|
|
const entries = []
|
|||
|
|
for (const ms of detail.model_skills) {
|
|||
|
|
for (const lev of detail.levels || []) {
|
|||
|
|
const key = `${ms.skill_id}-${lev.level_number}`
|
|||
|
|
const d = cellDraft[key] || { description: '', observable_criteria: '' }
|
|||
|
|
entries.push({
|
|||
|
|
skill_id: ms.skill_id,
|
|||
|
|
level_number: lev.level_number,
|
|||
|
|
description: (d.description || '').trim(),
|
|||
|
|
observable_criteria: (d.observable_criteria || '').trim() || null
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
await api.upsertMaturityModelSkillLevels(selectedId, { entries })
|
|||
|
|
await selectModel(selectedId)
|
|||
|
|
} catch (e) {
|
|||
|
|
setError(e.message)
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleAddSkill() {
|
|||
|
|
if (!selectedId || !skillToAdd) return
|
|||
|
|
setError('')
|
|||
|
|
setSaving(true)
|
|||
|
|
try {
|
|||
|
|
await api.addMaturityModelSkill(selectedId, { skill_id: parseInt(String(skillToAdd), 10) })
|
|||
|
|
setSkillToAdd('')
|
|||
|
|
await selectModel(selectedId)
|
|||
|
|
} catch (e) {
|
|||
|
|
setError(e.message)
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleRemoveSkill(skillId) {
|
|||
|
|
if (!selectedId) return
|
|||
|
|
if (!confirm('Fähigkeit aus dem Modell entfernen? Zelltexte werden gelöscht.')) return
|
|||
|
|
setError('')
|
|||
|
|
setSaving(true)
|
|||
|
|
try {
|
|||
|
|
await api.removeMaturityModelSkill(selectedId, skillId)
|
|||
|
|
await selectModel(selectedId)
|
|||
|
|
} catch (e) {
|
|||
|
|
setError(e.message)
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleDeleteModel() {
|
|||
|
|
if (!selectedId || !isSuperadmin) return
|
|||
|
|
if (!confirm('Reifegradmodell dauerhaft löschen?')) return
|
|||
|
|
setError('')
|
|||
|
|
setSaving(true)
|
|||
|
|
try {
|
|||
|
|
await api.deleteMaturityModel(selectedId)
|
|||
|
|
setSelectedId(null)
|
|||
|
|
setDetail(null)
|
|||
|
|
setMeta(null)
|
|||
|
|
await refreshModels()
|
|||
|
|
} catch (e) {
|
|||
|
|
setError(e.message)
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onLevelCountChange(raw) {
|
|||
|
|
const next = Math.max(3, Math.min(10, parseInt(String(raw), 10) || 3))
|
|||
|
|
setLevelCount(next)
|
|||
|
|
setLevelsForm((prev) => {
|
|||
|
|
const byNum = Object.fromEntries(prev.map((r) => [r.level_number, { ...r }]))
|
|||
|
|
const rows = []
|
|||
|
|
for (let i = 1; i <= next; i++) {
|
|||
|
|
rows.push(
|
|||
|
|
byNum[i] || {
|
|||
|
|
level_number: i,
|
|||
|
|
name: `Stufe ${i}`,
|
|||
|
|
description: '',
|
|||
|
|
sort_order: i
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
return rows
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function setCell(skillId, levelNumber, field, value) {
|
|||
|
|
const key = `${skillId}-${levelNumber}`
|
|||
|
|
setCellDraft((prev) => ({
|
|||
|
|
...prev,
|
|||
|
|
[key]: {
|
|||
|
|
description: field === 'description' ? value : (prev[key]?.description ?? ''),
|
|||
|
|
observable_criteria:
|
|||
|
|
field === 'observable_criteria' ? value : (prev[key]?.observable_criteria ?? '')
|
|||
|
|
}
|
|||
|
|
}))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!isAdmin) {
|
|||
|
|
return <Navigate to="/" replace />
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div style={{ padding: '20px', maxWidth: 1400, margin: '0 auto' }}>
|
|||
|
|
<AdminPageNav />
|
|||
|
|
|
|||
|
|
<h1 style={{ marginTop: 0 }}>Admin: Fähigkeitsmatrix</h1>
|
|||
|
|
<p style={{ color: 'var(--text2)', marginTop: '-8px' }}>
|
|||
|
|
Reifegradmodelle mit Fokusbereich, Stilrichtung und Zielgruppe. Pro Modell: Stufen definieren,
|
|||
|
|
Fähigkeiten zuordnen, Zelltexte pflegen.
|
|||
|
|
</p>
|
|||
|
|
|
|||
|
|
{error ? (
|
|||
|
|
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 16 }}>
|
|||
|
|
{error}
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
|
|||
|
|
<div
|
|||
|
|
style={{
|
|||
|
|
display: 'grid',
|
|||
|
|
gridTemplateColumns: 'minmax(240px, 320px) 1fr',
|
|||
|
|
gap: 20,
|
|||
|
|
alignItems: 'start'
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<div className="card" style={{ padding: 16 }}>
|
|||
|
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Modelle</h2>
|
|||
|
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
|||
|
|
{models.map((m) => (
|
|||
|
|
<li key={m.id} style={{ marginBottom: 8 }}>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className={selectedId === m.id ? 'btn btn-primary' : 'btn btn-secondary'}
|
|||
|
|
style={{ width: '100%', textAlign: 'left' }}
|
|||
|
|
onClick={() => selectModel(m.id)}
|
|||
|
|
>
|
|||
|
|
<div style={{ fontWeight: 600 }}>{m.name}</div>
|
|||
|
|
<div style={{ fontSize: 12, opacity: 0.85 }}>
|
|||
|
|
{[m.focus_area_name, m.style_direction_name, m.target_group_name]
|
|||
|
|
.filter(Boolean)
|
|||
|
|
.join(' · ') || 'Allgemein'}
|
|||
|
|
</div>
|
|||
|
|
<div style={{ fontSize: 11, opacity: 0.75 }}>{m.status} · {m.level_count} Stufen</div>
|
|||
|
|
</button>
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
|
|||
|
|
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '16px 0' }} />
|
|||
|
|
|
|||
|
|
<h3 style={{ fontSize: '1rem' }}>Neues Modell</h3>
|
|||
|
|
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|||
|
|
<label className="form-label">Name</label>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={newModel.name}
|
|||
|
|
onChange={(e) => setNewModel((s) => ({ ...s, name: e.target.value }))}
|
|||
|
|
required
|
|||
|
|
/>
|
|||
|
|
<label className="form-label">Stufenanzahl (3–10)</label>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
type="number"
|
|||
|
|
min={3}
|
|||
|
|
max={10}
|
|||
|
|
value={newModel.level_count}
|
|||
|
|
onChange={(e) => setNewModel((s) => ({ ...s, level_count: e.target.value }))}
|
|||
|
|
/>
|
|||
|
|
<label className="form-label">Status</label>
|
|||
|
|
<select
|
|||
|
|
className="form-input"
|
|||
|
|
value={newModel.status}
|
|||
|
|
onChange={(e) => setNewModel((s) => ({ ...s, status: e.target.value }))}
|
|||
|
|
>
|
|||
|
|
<option value="draft">Entwurf</option>
|
|||
|
|
<option value="active">Aktiv</option>
|
|||
|
|
<option value="archived">Archiviert</option>
|
|||
|
|
</select>
|
|||
|
|
<label className="form-label">Fokusbereich (optional)</label>
|
|||
|
|
<select
|
|||
|
|
className="form-input"
|
|||
|
|
value={newModel.focus_area_id}
|
|||
|
|
onChange={(e) => setNewModel((s) => ({ ...s, focus_area_id: e.target.value }))}
|
|||
|
|
>
|
|||
|
|
<option value="">—</option>
|
|||
|
|
{focusAreas.map((fa) => (
|
|||
|
|
<option key={fa.id} value={fa.id}>{fa.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
<label className="form-label">Stilrichtung (optional)</label>
|
|||
|
|
<select
|
|||
|
|
className="form-input"
|
|||
|
|
value={newModel.style_direction_id}
|
|||
|
|
onChange={(e) => setNewModel((s) => ({ ...s, style_direction_id: e.target.value }))}
|
|||
|
|
>
|
|||
|
|
<option value="">—</option>
|
|||
|
|
{styles.map((s) => (
|
|||
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
<label className="form-label">Zielgruppe (optional)</label>
|
|||
|
|
<select
|
|||
|
|
className="form-input"
|
|||
|
|
value={newModel.target_group_id}
|
|||
|
|
onChange={(e) => setNewModel((s) => ({ ...s, target_group_id: e.target.value }))}
|
|||
|
|
>
|
|||
|
|
<option value="">—</option>
|
|||
|
|
{targetGroups.map((tg) => (
|
|||
|
|
<option key={tg.id} value={tg.id}>{tg.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
<button type="submit" className="btn btn-primary btn-full" disabled={saving}>
|
|||
|
|
Anlegen
|
|||
|
|
</button>
|
|||
|
|
</form>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div>
|
|||
|
|
{loading ? <div className="spinner" /> : null}
|
|||
|
|
|
|||
|
|
{!loading && !detail && (
|
|||
|
|
<div className="card" style={{ padding: 24, color: 'var(--text2)' }}>
|
|||
|
|
Modell links wählen oder neu anlegen.
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{!loading && detail && meta && (
|
|||
|
|
<>
|
|||
|
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|||
|
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Kontext & Metadaten</h2>
|
|||
|
|
<div className="form-row" style={{ display: 'grid', gap: 12 }}>
|
|||
|
|
<div>
|
|||
|
|
<label className="form-label">Name</label>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={meta.name}
|
|||
|
|
onChange={(e) => setMeta((m) => ({ ...m, name: e.target.value }))}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="form-label">Beschreibung</label>
|
|||
|
|
<textarea
|
|||
|
|
className="form-input"
|
|||
|
|
rows={2}
|
|||
|
|
value={meta.description}
|
|||
|
|
onChange={(e) => setMeta((m) => ({ ...m, description: e.target.value }))}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|||
|
|
<div>
|
|||
|
|
<label className="form-label">Fokusbereich</label>
|
|||
|
|
<select
|
|||
|
|
className="form-input"
|
|||
|
|
value={meta.focus_area_id === null || meta.focus_area_id === '' ? '' : meta.focus_area_id}
|
|||
|
|
onChange={(e) => setMeta((m) => ({ ...m, focus_area_id: e.target.value }))}
|
|||
|
|
>
|
|||
|
|
<option value="">— (alle)</option>
|
|||
|
|
{focusAreas.map((fa) => (
|
|||
|
|
<option key={fa.id} value={fa.id}>{fa.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="form-label">Stilrichtung</label>
|
|||
|
|
<select
|
|||
|
|
className="form-input"
|
|||
|
|
value={
|
|||
|
|
meta.style_direction_id === null || meta.style_direction_id === ''
|
|||
|
|
? ''
|
|||
|
|
: meta.style_direction_id
|
|||
|
|
}
|
|||
|
|
onChange={(e) => setMeta((m) => ({ ...m, style_direction_id: e.target.value }))}
|
|||
|
|
>
|
|||
|
|
<option value="">— (alle)</option>
|
|||
|
|
{styles.map((s) => (
|
|||
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="form-label">Zielgruppe</label>
|
|||
|
|
<select
|
|||
|
|
className="form-input"
|
|||
|
|
value={
|
|||
|
|
meta.target_group_id === null || meta.target_group_id === ''
|
|||
|
|
? ''
|
|||
|
|
: meta.target_group_id
|
|||
|
|
}
|
|||
|
|
onChange={(e) => setMeta((m) => ({ ...m, target_group_id: e.target.value }))}
|
|||
|
|
>
|
|||
|
|
<option value="">— (alle)</option>
|
|||
|
|
{targetGroups.map((tg) => (
|
|||
|
|
<option key={tg.id} value={tg.id}>{tg.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|||
|
|
<div>
|
|||
|
|
<label className="form-label">Status</label>
|
|||
|
|
<select
|
|||
|
|
className="form-input"
|
|||
|
|
value={meta.status}
|
|||
|
|
onChange={(e) => setMeta((m) => ({ ...m, status: e.target.value }))}
|
|||
|
|
>
|
|||
|
|
<option value="draft">Entwurf</option>
|
|||
|
|
<option value="active">Aktiv</option>
|
|||
|
|
<option value="archived">Archiviert</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<label className="form-label">Version</label>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={meta.version}
|
|||
|
|
onChange={(e) => setMeta((m) => ({ ...m, version: e.target.value }))}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
|
|||
|
|
<button type="button" className="btn btn-primary" onClick={handleSaveMeta} disabled={saving}>
|
|||
|
|
Metadaten speichern
|
|||
|
|
</button>
|
|||
|
|
{isSuperadmin ? (
|
|||
|
|
<button type="button" className="btn btn-secondary" onClick={handleDeleteModel} disabled={saving}>
|
|||
|
|
Modell löschen
|
|||
|
|
</button>
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|||
|
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Stufen (Bezeichnungen)</h2>
|
|||
|
|
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 0 }}>
|
|||
|
|
Reihenfolge muss lückenlos 1…N sein. Stufenanzahl ändern passt die Tabelle an; danach speichern.
|
|||
|
|
</p>
|
|||
|
|
<div style={{ marginBottom: 12, maxWidth: 200 }}>
|
|||
|
|
<label className="form-label">Stufenanzahl (3–10)</label>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
type="number"
|
|||
|
|
min={3}
|
|||
|
|
max={10}
|
|||
|
|
value={levelCount}
|
|||
|
|
onChange={(e) => onLevelCountChange(e.target.value)}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ overflowX: 'auto' }}>
|
|||
|
|
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th style={{ textAlign: 'left', padding: 8 }}>Nr.</th>
|
|||
|
|
<th style={{ textAlign: 'left', padding: 8 }}>Name</th>
|
|||
|
|
<th style={{ textAlign: 'left', padding: 8 }}>Beschreibung</th>
|
|||
|
|
<th style={{ textAlign: 'left', padding: 8 }}>Sort</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{levelsForm.map((row, idx) => (
|
|||
|
|
<tr key={row.level_number} style={{ borderTop: '1px solid var(--border)' }}>
|
|||
|
|
<td style={{ padding: 8 }}>{row.level_number}</td>
|
|||
|
|
<td style={{ padding: 8 }}>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={row.name}
|
|||
|
|
onChange={(e) => {
|
|||
|
|
const next = [...levelsForm]
|
|||
|
|
next[idx] = { ...row, name: e.target.value }
|
|||
|
|
setLevelsForm(next)
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</td>
|
|||
|
|
<td style={{ padding: 8 }}>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
value={row.description}
|
|||
|
|
onChange={(e) => {
|
|||
|
|
const next = [...levelsForm]
|
|||
|
|
next[idx] = { ...row, description: e.target.value }
|
|||
|
|
setLevelsForm(next)
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</td>
|
|||
|
|
<td style={{ padding: 8, width: 72 }}>
|
|||
|
|
<input
|
|||
|
|
className="form-input"
|
|||
|
|
type="number"
|
|||
|
|
value={row.sort_order}
|
|||
|
|
onChange={(e) => {
|
|||
|
|
const next = [...levelsForm]
|
|||
|
|
next[idx] = { ...row, sort_order: e.target.value }
|
|||
|
|
setLevelsForm(next)
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className="btn btn-secondary"
|
|||
|
|
style={{ marginTop: 12 }}
|
|||
|
|
onClick={handleSaveLevels}
|
|||
|
|
disabled={saving}
|
|||
|
|
>
|
|||
|
|
Stufen speichern
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
|||
|
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Fähigkeiten im Modell</h2>
|
|||
|
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
|||
|
|
<div style={{ flex: '1 1 220px' }}>
|
|||
|
|
<label className="form-label">Fähigkeit hinzufügen</label>
|
|||
|
|
<select
|
|||
|
|
className="form-input"
|
|||
|
|
value={skillToAdd}
|
|||
|
|
onChange={(e) => setSkillToAdd(e.target.value)}
|
|||
|
|
>
|
|||
|
|
<option value="">— wählen —</option>
|
|||
|
|
{allSkills.map((s) => (
|
|||
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|||
|
|
))}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<button type="button" className="btn btn-primary" onClick={handleAddSkill} disabled={saving || !skillToAdd}>
|
|||
|
|
Hinzufügen
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<ul style={{ marginTop: 12, paddingLeft: 18 }}>
|
|||
|
|
{(detail.model_skills || []).map((ms) => (
|
|||
|
|
<li key={ms.skill_id} style={{ marginBottom: 6 }}>
|
|||
|
|
<strong>{ms.skill_name}</strong>
|
|||
|
|
{' '}
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className="btn btn-secondary"
|
|||
|
|
style={{ padding: '2px 8px', fontSize: 12 }}
|
|||
|
|
onClick={() => handleRemoveSkill(ms.skill_id)}
|
|||
|
|
>
|
|||
|
|
entfernen
|
|||
|
|
</button>
|
|||
|
|
</li>
|
|||
|
|
))}
|
|||
|
|
</ul>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="card" style={{ padding: 16 }}>
|
|||
|
|
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Matrix (Zielbild je Stufe)</h2>
|
|||
|
|
<p style={{ fontSize: 14, color: 'var(--text2)', marginTop: 0 }}>
|
|||
|
|
Leere Zellen werden beim Speichern aus der Datenbank entfernt. Beobachtungskriterien optional in
|
|||
|
|
zweiter Zeile (nach Speichern mit Beschreibung).
|
|||
|
|
</p>
|
|||
|
|
<div style={{ overflow: 'auto', maxHeight: '70vh' }}>
|
|||
|
|
<table style={{ borderCollapse: 'collapse', fontSize: 13, minWidth: 600 }}>
|
|||
|
|
<thead>
|
|||
|
|
<tr>
|
|||
|
|
<th style={{ padding: 8, border: '1px solid var(--border)', position: 'sticky', left: 0, background: 'var(--surface)' }}>
|
|||
|
|
Fähigkeit
|
|||
|
|
</th>
|
|||
|
|
{(detail.levels || []).map((l) => (
|
|||
|
|
<th
|
|||
|
|
key={l.level_number}
|
|||
|
|
style={{ padding: 8, border: '1px solid var(--border)', minWidth: 160, background: 'var(--surface2)' }}
|
|||
|
|
>
|
|||
|
|
{l.level_number}. {l.name}
|
|||
|
|
</th>
|
|||
|
|
))}
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{(detail.model_skills || []).map((ms) => (
|
|||
|
|
<tr key={ms.skill_id}>
|
|||
|
|
<td
|
|||
|
|
style={{
|
|||
|
|
padding: 8,
|
|||
|
|
border: '1px solid var(--border)',
|
|||
|
|
position: 'sticky',
|
|||
|
|
left: 0,
|
|||
|
|
background: 'var(--surface)',
|
|||
|
|
fontWeight: 600,
|
|||
|
|
whiteSpace: 'nowrap'
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{ms.skill_name}
|
|||
|
|
</td>
|
|||
|
|
{(detail.levels || []).map((l) => {
|
|||
|
|
const key = `${ms.skill_id}-${l.level_number}`
|
|||
|
|
const d = cellDraft[key] || { description: '', observable_criteria: '' }
|
|||
|
|
return (
|
|||
|
|
<td key={l.level_number} style={{ padding: 6, border: '1px solid var(--border)', verticalAlign: 'top' }}>
|
|||
|
|
<textarea
|
|||
|
|
className="form-input"
|
|||
|
|
rows={3}
|
|||
|
|
placeholder="Zielbild / Erwartung"
|
|||
|
|
value={d.description}
|
|||
|
|
onChange={(e) => setCell(ms.skill_id, l.level_number, 'description', e.target.value)}
|
|||
|
|
style={{ fontSize: 12, width: '100%', minWidth: 140 }}
|
|||
|
|
/>
|
|||
|
|
<textarea
|
|||
|
|
className="form-input"
|
|||
|
|
rows={2}
|
|||
|
|
placeholder="Beobachtungskriterien (optional)"
|
|||
|
|
value={d.observable_criteria}
|
|||
|
|
onChange={(e) => setCell(ms.skill_id, l.level_number, 'observable_criteria', e.target.value)}
|
|||
|
|
style={{ fontSize: 11, width: '100%', minWidth: 140, marginTop: 4 }}
|
|||
|
|
/>
|
|||
|
|
</td>
|
|||
|
|
)
|
|||
|
|
})}
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
className="btn btn-primary"
|
|||
|
|
style={{ marginTop: 12 }}
|
|||
|
|
onClick={handleSaveMatrix}
|
|||
|
|
disabled={saving || !(detail.model_skills || []).length}
|
|||
|
|
>
|
|||
|
|
Matrix speichern
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|