2026-04-27 11:32:30 +02:00
|
|
|
|
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'
|
|
|
|
|
|
|
2026-04-27 11:42:03 +02:00
|
|
|
|
function MultiIdSelect({ label, options, valueIds, onChange, hint }) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<label className="form-label">{label}</label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
multiple
|
|
|
|
|
|
className="form-input"
|
|
|
|
|
|
style={{ minHeight: 100, width: '100%' }}
|
|
|
|
|
|
value={(valueIds || []).map(String)}
|
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
|
const v = Array.from(e.target.selectedOptions, (o) => parseInt(o.value, 10))
|
|
|
|
|
|
onChange(v)
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{options.map((o) => (
|
|
|
|
|
|
<option key={o.id} value={o.id}>{o.name}</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
{hint ? (
|
|
|
|
|
|
<div style={{ fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>{hint}</div>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-27 11:32:30 +02:00
|
|
|
|
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,
|
2026-04-27 11:42:03 +02:00
|
|
|
|
focus_area_ids: [],
|
|
|
|
|
|
style_direction_ids: [],
|
|
|
|
|
|
target_group_ids: [],
|
2026-04-27 11:32:30 +02:00
|
|
|
|
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 || '',
|
2026-04-27 11:42:03 +02:00
|
|
|
|
focus_area_ids: (d.focus_areas || []).map((x) => x.id),
|
|
|
|
|
|
style_direction_ids: (d.style_directions || []).map((x) => x.id),
|
|
|
|
|
|
target_group_ids: (d.target_groups || []).map((x) => x.id),
|
2026-04-27 11:32:30 +02:00
|
|
|
|
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,
|
2026-04-27 11:42:03 +02:00
|
|
|
|
focus_area_ids: newModel.focus_area_ids,
|
|
|
|
|
|
style_direction_ids: newModel.style_direction_ids,
|
|
|
|
|
|
target_group_ids: newModel.target_group_ids
|
2026-04-27 11:32:30 +02:00
|
|
|
|
}
|
|
|
|
|
|
const created = await api.createMaturityModel(payload)
|
|
|
|
|
|
await refreshModels()
|
|
|
|
|
|
await selectModel(created.id)
|
|
|
|
|
|
setNewModel({
|
|
|
|
|
|
name: '',
|
|
|
|
|
|
level_count: 5,
|
2026-04-27 11:42:03 +02:00
|
|
|
|
focus_area_ids: [],
|
|
|
|
|
|
style_direction_ids: [],
|
|
|
|
|
|
target_group_ids: [],
|
2026-04-27 11:32:30 +02:00
|
|
|
|
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,
|
2026-04-27 11:42:03 +02:00
|
|
|
|
focus_area_ids: meta.focus_area_ids,
|
|
|
|
|
|
style_direction_ids: meta.style_direction_ids,
|
|
|
|
|
|
target_group_ids: meta.target_group_ids,
|
2026-04-27 11:32:30 +02:00
|
|
|
|
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>
|
2026-04-27 11:42:03 +02:00
|
|
|
|
<div style={{ fontSize: 12, opacity: 0.85, lineHeight: 1.35 }}>
|
|
|
|
|
|
{(m.focus_areas || []).length
|
|
|
|
|
|
? `Fokus: ${m.focus_areas.map((f) => f.name).join(', ')}`
|
|
|
|
|
|
: 'Fokus: alle'}
|
|
|
|
|
|
<br />
|
|
|
|
|
|
{(m.style_directions || []).length
|
|
|
|
|
|
? `Stil: ${m.style_directions.map((s) => s.name).join(', ')}`
|
|
|
|
|
|
: 'Stil: alle'}
|
|
|
|
|
|
<br />
|
|
|
|
|
|
{(m.target_groups || []).length
|
|
|
|
|
|
? `Zielgr.: ${m.target_groups.map((t) => t.name).join(', ')}`
|
|
|
|
|
|
: 'Zielgr.: alle'}
|
2026-04-27 11:32:30 +02:00
|
|
|
|
</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>
|
2026-04-27 11:42:03 +02:00
|
|
|
|
<MultiIdSelect
|
|
|
|
|
|
label="Fokusbereiche (leer = alle)"
|
|
|
|
|
|
options={focusAreas}
|
|
|
|
|
|
valueIds={newModel.focus_area_ids}
|
|
|
|
|
|
onChange={(ids) => setNewModel((s) => ({ ...s, focus_area_ids: ids }))}
|
|
|
|
|
|
hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
|
|
|
|
|
|
/>
|
|
|
|
|
|
<MultiIdSelect
|
|
|
|
|
|
label="Stilrichtungen (leer = alle)"
|
|
|
|
|
|
options={styles}
|
|
|
|
|
|
valueIds={newModel.style_direction_ids}
|
|
|
|
|
|
onChange={(ids) => setNewModel((s) => ({ ...s, style_direction_ids: ids }))}
|
|
|
|
|
|
hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
|
|
|
|
|
|
/>
|
|
|
|
|
|
<MultiIdSelect
|
|
|
|
|
|
label="Zielgruppen (leer = alle)"
|
|
|
|
|
|
options={targetGroups}
|
|
|
|
|
|
valueIds={newModel.target_group_ids}
|
|
|
|
|
|
onChange={(ids) => setNewModel((s) => ({ ...s, target_group_ids: ids }))}
|
|
|
|
|
|
hint="Strg/Cmd gedrückt halten für Mehrfachauswahl."
|
|
|
|
|
|
/>
|
2026-04-27 11:32:30 +02:00
|
|
|
|
<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>
|
2026-04-27 11:42:03 +02:00
|
|
|
|
<MultiIdSelect
|
|
|
|
|
|
label="Fokusbereiche (keine Auswahl = alle)"
|
|
|
|
|
|
options={focusAreas}
|
|
|
|
|
|
valueIds={meta.focus_area_ids}
|
|
|
|
|
|
onChange={(ids) => setMeta((m) => ({ ...m, focus_area_ids: ids }))}
|
|
|
|
|
|
hint="Strg/Cmd + Klick für mehrere. Alle abwählen = gilt in jedem Fokusbereich."
|
|
|
|
|
|
/>
|
|
|
|
|
|
<MultiIdSelect
|
|
|
|
|
|
label="Stilrichtungen (keine Auswahl = alle)"
|
|
|
|
|
|
options={styles}
|
|
|
|
|
|
valueIds={meta.style_direction_ids}
|
|
|
|
|
|
onChange={(ids) => setMeta((m) => ({ ...m, style_direction_ids: ids }))}
|
|
|
|
|
|
hint="Strg/Cmd + Klick für mehrere."
|
|
|
|
|
|
/>
|
|
|
|
|
|
<MultiIdSelect
|
|
|
|
|
|
label="Zielgruppen (keine Auswahl = alle)"
|
|
|
|
|
|
options={targetGroups}
|
|
|
|
|
|
valueIds={meta.target_group_ids}
|
|
|
|
|
|
onChange={(ids) => setMeta((m) => ({ ...m, target_group_ids: ids }))}
|
|
|
|
|
|
hint="Strg/Cmd + Klick für mehrere."
|
|
|
|
|
|
/>
|
2026-04-27 11:32:30 +02:00
|
|
|
|
<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) => (
|
2026-04-27 11:42:03 +02:00
|
|
|
|
<li key={ms.skill_id} style={{ marginBottom: 10 }}>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
|
|
|
|
|
|
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 2 }}>
|
|
|
|
|
|
{ms.skill_main_category_name}
|
|
|
|
|
|
{ms.skill_subcategory_name ? ` › ${ms.skill_subcategory_name}` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-04-27 11:32:30 +02:00
|
|
|
|
</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,
|
2026-04-27 11:42:03 +02:00
|
|
|
|
maxWidth: 220,
|
|
|
|
|
|
verticalAlign: 'top'
|
2026-04-27 11:32:30 +02:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-27 11:42:03 +02:00
|
|
|
|
<div>{ms.skill_name}</div>
|
|
|
|
|
|
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
|
|
|
|
|
|
<div style={{ fontSize: 11, fontWeight: 400, color: 'var(--text2)', marginTop: 4, lineHeight: 1.3 }}>
|
|
|
|
|
|
{ms.skill_main_category_name || '—'}
|
|
|
|
|
|
{ms.skill_subcategory_name ? ` › ${ms.skill_subcategory_name}` : ''}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : null}
|
2026-04-27 11:32:30 +02:00
|
|
|
|
</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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|