shinkan-jinkendo/frontend/src/pages/AdminMaturityModelsPage.jsx

746 lines
28 KiB
React
Raw Normal View History

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'
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>
)
}
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_ids: [],
style_direction_ids: [],
target_group_ids: [],
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_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),
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_ids: newModel.focus_area_ids,
style_direction_ids: newModel.style_direction_ids,
target_group_ids: newModel.target_group_ids
}
const created = await api.createMaturityModel(payload)
await refreshModels()
await selectModel(created.id)
setNewModel({
name: '',
level_count: 5,
focus_area_ids: [],
style_direction_ids: [],
target_group_ids: [],
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_ids: meta.focus_area_ids,
style_direction_ids: meta.style_direction_ids,
target_group_ids: meta.target_group_ids,
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, 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'}
</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>
<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."
/>
<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 &amp; 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>
<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."
/>
<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: 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}
</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,
maxWidth: 220,
verticalAlign: 'top'
}}
>
<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}
</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>
)
}