feat: enhance admin UI for maturity models and skills catalog
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 6s
Test Suite / playwright-tests (push) Failing after 1m55s

- Added new styles and layout for the maturity models admin panel, improving user experience.
- Removed unused multi-selection component for focus areas, style directions, and target groups.
- Updated skills catalog functionality to support editing and sorting of main categories and skills.
- Introduced a modal for editing main categories, categories, and skills with improved accessibility.
- Enhanced responsiveness and visual consistency across admin components.
This commit is contained in:
Lars 2026-04-27 12:19:22 +02:00
parent e8b7e62832
commit 469ec93074
3 changed files with 1008 additions and 546 deletions

View File

@ -1248,6 +1248,426 @@ a.analysis-split__nav-item {
min-width: 2rem;
}
.btn-ghost {
background: transparent;
border: none;
color: var(--text2);
box-shadow: none;
}
.btn-ghost:hover {
background: var(--surface2);
color: var(--text1);
}
/* Touch-Ziel mind. ca. 44×44 (Apple HIG) */
.btn-icon-touch {
min-width: 44px;
min-height: 44px;
padding: 0 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.15rem;
line-height: 1;
border-radius: 10px;
}
.admin-modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
}
@media (min-width: 640px) {
.admin-modal-backdrop {
align-items: center;
padding: 24px;
padding-bottom: max(24px, env(safe-area-inset-bottom, 0px));
}
}
.admin-modal-sheet {
width: 100%;
max-width: 520px;
max-height: min(92vh, 100dvh);
background: var(--surface);
border-radius: 16px 16px 0 0;
border: 1px solid var(--border);
border-bottom: none;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
overflow: hidden;
}
@media (min-width: 640px) {
.admin-modal-sheet {
border-radius: 16px;
border-bottom: 1px solid var(--border);
max-height: min(88vh, 900px);
}
}
.admin-modal-sheet__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 16px 12px;
padding-top: max(16px, env(safe-area-inset-top, 0px));
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.admin-modal-sheet__title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
line-height: 1.25;
}
.admin-modal-sheet__close {
flex-shrink: 0;
min-width: 44px;
min-height: 44px;
padding: 0 12px;
}
.admin-modal-sheet__body {
padding: 16px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* Reifegradmodell-Admin: klare Schritte, responsives Raster */
.admin-matrix-alert {
border: 1px solid var(--danger);
padding: 14px 16px;
margin-bottom: 16px;
border-radius: 12px;
color: var(--danger);
}
.admin-matrix-layout {
display: grid;
grid-template-columns: minmax(260px, 300px) 1fr;
gap: 20px;
align-items: start;
}
@media (max-width: 900px) {
.admin-matrix-layout {
grid-template-columns: 1fr;
}
}
.admin-matrix-sidebar {
position: sticky;
top: 12px;
}
@media (max-width: 900px) {
.admin-matrix-sidebar {
position: static;
}
}
.admin-matrix-sidebar__title {
margin: 0 0 12px;
font-size: 1.05rem;
font-weight: 700;
}
.admin-matrix-sidebar__subtitle {
margin: 0 0 10px;
font-size: 0.95rem;
font-weight: 600;
}
.admin-matrix-divider {
border: none;
border-top: 1px solid var(--border);
margin: 16px 0;
}
.admin-matrix-model-list {
list-style: none;
margin: 0;
padding: 0;
max-height: min(40vh, 320px);
overflow-y: auto;
}
.admin-matrix-model-list li + li {
margin-top: 8px;
}
.admin-matrix-model-btn {
width: 100%;
text-align: left;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--border2);
background: var(--surface2);
color: var(--text1);
font: inherit;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 48px;
}
.admin-matrix-model-btn:hover {
background: var(--surface);
}
.admin-matrix-model-btn--active {
border-color: var(--accent);
background: var(--accent-light);
color: var(--accent-dark);
}
@media (prefers-color-scheme: dark) {
.admin-matrix-model-btn--active {
color: var(--accent);
}
}
.admin-matrix-model-btn__name {
font-weight: 600;
}
.admin-matrix-model-btn__meta {
font-size: 12px;
}
.admin-matrix-new-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.admin-matrix-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-matrix-loading {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 0;
}
.admin-matrix-empty {
padding: 24px;
color: var(--text2);
}
.admin-matrix-section__head {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.admin-matrix-section__title {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
}
.admin-matrix-step {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
color: #fff;
font-size: 15px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
}
.admin-matrix-hint {
font-size: 14px;
margin: 0 0 12px;
line-height: 1.45;
}
.admin-matrix-meta-grid {
display: grid;
gap: 12px;
margin-bottom: 12px;
}
.admin-matrix-meta-grid__row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 600px) {
.admin-matrix-meta-grid__row2 {
grid-template-columns: 1fr;
}
}
.admin-matrix-context-readonly {
font-size: 13px;
line-height: 1.5;
padding: 12px 14px;
border-radius: 10px;
background: var(--surface2);
border: 1px dashed var(--border2);
margin-bottom: 12px;
}
.admin-matrix-context-readonly strong {
color: var(--text2);
}
.admin-matrix-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
}
.admin-matrix-actions--mt {
margin-top: 12px;
}
.admin-matrix-level-count {
max-width: 220px;
margin-bottom: 12px;
}
.admin-matrix-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin-bottom: 8px;
}
.admin-matrix-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.admin-matrix-table th,
.admin-matrix-table td {
text-align: left;
padding: 8px;
border-top: 1px solid var(--border);
vertical-align: middle;
}
.admin-matrix-table thead th {
border-top: none;
font-weight: 600;
color: var(--text2);
font-size: 13px;
}
.admin-matrix-table__narrow {
width: 88px;
}
.admin-matrix-skill-add {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
}
.admin-matrix-skill-add__select {
flex: 1 1 220px;
min-width: 0;
}
.admin-matrix-skill-add__btn {
flex-shrink: 0;
min-height: 44px;
}
.admin-matrix-skill-list {
margin: 12px 0 0;
padding: 0;
list-style: none;
}
.admin-matrix-skill-list__item {
padding: 12px 0;
border-top: 1px solid var(--border);
}
.admin-matrix-skill-list__item:first-child {
border-top: none;
}
.admin-matrix-skill-list__row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.admin-matrix-skill-list__name {
font-size: 15px;
}
.admin-matrix-skill-list__path {
font-size: 12px;
margin-top: 4px;
}
.admin-matrix-matrix-scroll {
overflow: auto;
max-height: min(70vh, 720px);
-webkit-overflow-scrolling: touch;
border: 1px solid var(--border);
border-radius: 10px;
}
.admin-matrix-table--matrix th,
.admin-matrix-table--matrix td {
border: 1px solid var(--border);
}
.admin-matrix-matrix__corner {
position: sticky;
left: 0;
z-index: 2;
background: var(--surface);
min-width: 140px;
padding: 8px;
}
.admin-matrix-matrix__level-head {
padding: 8px;
min-width: 160px;
background: var(--surface2);
font-size: 13px;
}
.admin-matrix-matrix__skill-cell {
position: sticky;
left: 0;
z-index: 1;
background: var(--surface);
font-weight: 600;
max-width: 220px;
vertical-align: top;
padding: 8px;
}
.admin-matrix-matrix__skill-path {
font-size: 11px;
font-weight: 400;
margin-top: 6px;
line-height: 1.35;
}
.admin-matrix-matrix__cell {
vertical-align: top;
padding: 6px;
min-width: 148px;
}
.admin-matrix-matrix__ta {
font-size: 12px;
width: 100%;
min-width: 120px;
}
.admin-matrix-matrix__ta--criteria {
font-size: 11px;
margin-top: 6px;
}
.btn-small {
padding: 8px 12px;
font-size: 13px;
min-height: 40px;
}
.muted { color: var(--text3); font-size: 13px; }
.empty-state { text-align: center; padding: 48px 16px; color: var(--text3); }
.empty-state h3 { font-size: 16px; color: var(--text2); margin-bottom: 6px; }

View File

@ -2,31 +2,6 @@ import React, { useEffect, useState } from 'react'
import { useAuth } from '../../context/AuthContext'
import api from '../../utils/api'
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 MaturityModelsAdminPanel() {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
@ -37,17 +12,11 @@ export default function MaturityModelsAdminPanel() {
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'
})
@ -61,18 +30,12 @@ export default function MaturityModelsAdminPanel() {
let cancelled = false
;(async () => {
try {
const [m, fa, sd, tg, sk] = await Promise.all([
const [m, 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) {
@ -97,9 +60,6 @@ export default function MaturityModelsAdminPanel() {
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'
})
@ -138,10 +98,7 @@ export default function MaturityModelsAdminPanel() {
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
status: newModel.status
}
const created = await api.createMaturityModel(payload)
await refreshModels()
@ -149,9 +106,6 @@ export default function MaturityModelsAdminPanel() {
setNewModel({
name: '',
level_count: 5,
focus_area_ids: [],
style_direction_ids: [],
target_group_ids: [],
status: 'draft'
})
} catch (e) {
@ -169,9 +123,6 @@ export default function MaturityModelsAdminPanel() {
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
})
@ -321,58 +272,45 @@ export default function MaturityModelsAdminPanel() {
return (
<div className="admin-matrix-panel">
<p className="admin-matrix-panel__intro muted">
Reifegradmodelle: Kontext (Fokus / Stil / Zielgruppe), Stufen, Matrix-Zelltexte. Die Fähigkeiten selbst pflegen Sie im Tab Katalog und Hierarchie.
Ablauf: Modell wählen Stammdaten Stufen definieren Fähigkeiten zuordnen Matrix-Zelltexte pflegen.
Fähigkeiten legen Sie im Tab Katalog und Hierarchie an.{' '}
<strong>Zuordnung zu Fokusbereich / Stil / Zielgruppe</strong> ist hier vorerst deaktiviert; bestehende
Einträge in der Datenbank bleiben unverändert, bis wir das Konzept (Überschneidungen) geklärt haben.
</p>
{error ? (
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 16 }}>
<div className="card admin-matrix-alert" role="alert">
{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 }}>
<div className="admin-matrix-layout">
<aside className="admin-matrix-sidebar card">
<h2 className="admin-matrix-sidebar__title">Reifegradmodelle</h2>
<ul className="admin-matrix-model-list">
{models.map((m) => (
<li key={m.id} style={{ marginBottom: 8 }}>
<li key={m.id}>
<button
type="button"
className={selectedId === m.id ? 'btn btn-primary' : 'btn btn-secondary'}
style={{ width: '100%', textAlign: 'left' }}
className={
'admin-matrix-model-btn' +
(selectedId === m.id ? ' admin-matrix-model-btn--active' : '')
}
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>
<span className="admin-matrix-model-btn__name">{m.name}</span>
<span className="admin-matrix-model-btn__meta muted">
{m.status} · {m.level_count} Stufen
</span>
</button>
</li>
))}
</ul>
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '16px 0' }} />
<hr className="admin-matrix-divider" />
<h3 style={{ fontSize: '1rem' }}>Neues Modell</h3>
<form onSubmit={handleCreate} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<h3 className="admin-matrix-sidebar__subtitle">Neues Modell</h3>
<form className="admin-matrix-new-form" onSubmit={handleCreate}>
<label className="form-label">Name</label>
<input
className="form-input"
@ -386,6 +324,7 @@ export default function MaturityModelsAdminPanel() {
type="number"
min={3}
max={10}
inputMode="numeric"
value={newModel.level_count}
onChange={(e) => setNewModel((s) => ({ ...s, level_count: e.target.value }))}
/>
@ -399,47 +338,34 @@ export default function MaturityModelsAdminPanel() {
<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>
</aside>
<div>
{loading ? <div className="spinner" /> : null}
<div className="admin-matrix-main">
{loading ? (
<div className="admin-matrix-loading" aria-busy="true">
<div className="spinner" />
<span className="muted">Lade Modell</span>
</div>
) : null}
{!loading && !detail && (
<div className="card" style={{ padding: 24, color: 'var(--text2)' }}>
Modell links wählen oder neu anlegen.
<div className="card admin-matrix-empty">
Modell in der linken Spalte 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 }}>
<section className="card admin-matrix-section">
<div className="admin-matrix-section__head">
<span className="admin-matrix-step">1</span>
<h2 className="admin-matrix-section__title">Stammdaten</h2>
</div>
<div className="admin-matrix-meta-grid">
<div>
<label className="form-label">Name</label>
<input
@ -457,28 +383,7 @@ export default function MaturityModelsAdminPanel() {
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 className="admin-matrix-meta-grid__row2">
<div>
<label className="form-label">Status</label>
<select
@ -501,9 +406,26 @@ export default function MaturityModelsAdminPanel() {
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
{((detail.focus_areas || []).length > 0 ||
(detail.style_directions || []).length > 0 ||
(detail.target_groups || []).length > 0) && (
<div className="admin-matrix-context-readonly muted">
<strong>Hinweis (nur Anzeige):</strong> In der Datenbank sind noch Kontext-Zuordnungen
hinterlegt (Fokus / Stil / Zielgruppe). Diese können Sie hier derzeit nicht ändern.
{(detail.focus_areas || []).length ? (
<div>Fokusbereiche: {(detail.focus_areas || []).map((f) => f.name).join(', ')}</div>
) : null}
{(detail.style_directions || []).length ? (
<div>Stilrichtungen: {(detail.style_directions || []).map((s) => s.name).join(', ')}</div>
) : null}
{(detail.target_groups || []).length ? (
<div>Zielgruppen: {(detail.target_groups || []).map((t) => t.name).join(', ')}</div>
) : null}
</div>
)}
<div className="admin-matrix-actions">
<button type="button" className="btn btn-primary" onClick={handleSaveMeta} disabled={saving}>
Metadaten speichern
Stammdaten speichern
</button>
{isSuperadmin ? (
<button type="button" className="btn btn-secondary" onClick={handleDeleteModel} disabled={saving}>
@ -511,39 +433,43 @@ export default function MaturityModelsAdminPanel() {
</button>
) : null}
</div>
</div>
</section>
<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 1N sein. Stufenanzahl ändern passt die Tabelle an; danach speichern.
<section className="card admin-matrix-section">
<div className="admin-matrix-section__head">
<span className="admin-matrix-step">2</span>
<h2 className="admin-matrix-section__title">Stufen (Bezeichnungen)</h2>
</div>
<p className="admin-matrix-hint muted">
Nummern 1N lückenlos. Stufenanzahl ändert die Matrix; danach Stufen speichern.
</p>
<div style={{ marginBottom: 12, maxWidth: 200 }}>
<div className="admin-matrix-level-count">
<label className="form-label">Stufenanzahl (310)</label>
<input
className="form-input"
type="number"
min={3}
max={10}
inputMode="numeric"
value={levelCount}
onChange={(e) => onLevelCountChange(e.target.value)}
/>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<div className="admin-matrix-table-wrap">
<table className="admin-matrix-table admin-matrix-table--levels">
<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>
<th>Nr.</th>
<th>Name</th>
<th>Beschreibung</th>
<th>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 }}>
<tr key={row.level_number}>
<td>{row.level_number}</td>
<td>
<input
className="form-input"
value={row.name}
@ -554,7 +480,7 @@ export default function MaturityModelsAdminPanel() {
}}
/>
</td>
<td style={{ padding: 8 }}>
<td>
<input
className="form-input"
value={row.description}
@ -565,10 +491,11 @@ export default function MaturityModelsAdminPanel() {
}}
/>
</td>
<td style={{ padding: 8, width: 72 }}>
<td className="admin-matrix-table__narrow">
<input
className="form-input"
type="number"
inputMode="numeric"
value={row.sort_order}
onChange={(e) => {
const next = [...levelsForm]
@ -584,19 +511,21 @@ export default function MaturityModelsAdminPanel() {
</div>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 12 }}
className="btn btn-secondary admin-matrix-actions--mt"
onClick={handleSaveLevels}
disabled={saving}
>
Stufen speichern
</button>
</div>
</section>
<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' }}>
<section className="card admin-matrix-section">
<div className="admin-matrix-section__head">
<span className="admin-matrix-step">3</span>
<h2 className="admin-matrix-section__title">Fähigkeiten im Modell</h2>
</div>
<div className="admin-matrix-skill-add">
<div className="admin-matrix-skill-add__select">
<label className="form-label">Fähigkeit hinzufügen</label>
<select
className="form-input"
@ -609,27 +538,30 @@ export default function MaturityModelsAdminPanel() {
))}
</select>
</div>
<button type="button" className="btn btn-primary" onClick={handleAddSkill} disabled={saving || !skillToAdd}>
<button
type="button"
className="btn btn-primary admin-matrix-skill-add__btn"
onClick={handleAddSkill}
disabled={saving || !skillToAdd}
>
Hinzufügen
</button>
</div>
<ul style={{ marginTop: 12, paddingLeft: 18 }}>
<ul className="admin-matrix-skill-list">
{(detail.model_skills || []).map((ms) => (
<li key={ms.skill_id} style={{ marginBottom: 10 }}>
<div>
<strong>{ms.skill_name}</strong>
{' '}
<li key={ms.skill_id} className="admin-matrix-skill-list__item">
<div className="admin-matrix-skill-list__row">
<strong className="admin-matrix-skill-list__name">{ms.skill_name}</strong>
<button
type="button"
className="btn btn-secondary"
style={{ padding: '2px 8px', fontSize: 12 }}
className="btn btn-secondary btn-small"
onClick={() => handleRemoveSkill(ms.skill_id)}
>
entfernen
Entfernen
</button>
</div>
{(ms.skill_main_category_name || ms.skill_subcategory_name) ? (
<div style={{ fontSize: 12, color: 'var(--text2)', marginTop: 2 }}>
<div className="admin-matrix-skill-list__path muted">
{ms.skill_main_category_name}
{ms.skill_subcategory_name ? ` ${ms.skill_subcategory_name}` : ''}
</div>
@ -637,26 +569,23 @@ export default function MaturityModelsAdminPanel() {
</li>
))}
</ul>
</div>
</section>
<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).
<section className="card admin-matrix-section">
<div className="admin-matrix-section__head">
<span className="admin-matrix-step">4</span>
<h2 className="admin-matrix-section__title">Matrix (Zielbild je Stufe)</h2>
</div>
<p className="admin-matrix-hint muted">
Leere Zellen werden beim Speichern entfernt. Unten: Beobachtungskriterien (optional).
</p>
<div style={{ overflow: 'auto', maxHeight: '70vh' }}>
<table style={{ borderCollapse: 'collapse', fontSize: 13, minWidth: 600 }}>
<div className="admin-matrix-matrix-scroll">
<table className="admin-matrix-table admin-matrix-table--matrix">
<thead>
<tr>
<th style={{ padding: 8, border: '1px solid var(--border)', position: 'sticky', left: 0, background: 'var(--surface)' }}>
Fähigkeit
</th>
<th className="admin-matrix-matrix__corner">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)' }}
>
<th key={l.level_number} className="admin-matrix-matrix__level-head">
{l.level_number}. {l.name}
</th>
))}
@ -665,21 +594,10 @@ export default function MaturityModelsAdminPanel() {
<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'
}}
>
<td className="admin-matrix-matrix__skill-cell">
<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 }}>
<div className="admin-matrix-matrix__skill-path muted">
{ms.skill_main_category_name || '—'}
{ms.skill_subcategory_name ? ` ${ms.skill_subcategory_name}` : ''}
</div>
@ -689,22 +607,20 @@ export default function MaturityModelsAdminPanel() {
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' }}>
<td key={l.level_number} className="admin-matrix-matrix__cell">
<textarea
className="form-input"
className="form-input admin-matrix-matrix__ta"
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"
className="form-input admin-matrix-matrix__ta admin-matrix-matrix__ta--criteria"
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>
)
@ -716,14 +632,13 @@ export default function MaturityModelsAdminPanel() {
</div>
<button
type="button"
className="btn btn-primary"
style={{ marginTop: 12 }}
className="btn btn-primary admin-matrix-actions--mt"
onClick={handleSaveMatrix}
disabled={saving || !(detail.model_skills || []).length}
>
Matrix speichern
</button>
</div>
</section>
</>
)}
</div>

View File

@ -9,17 +9,29 @@ function bySortThenName(a, b) {
return String(a.name || '').localeCompare(String(b.name || ''), 'de')
}
async function swapNeighborSort(list, index, delta, updateId) {
const sorted = [...list].sort(bySortThenName)
const i = sorted.findIndex((x) => x.id === list[index]?.id)
/** Tauscht sort_order mit dem direkten Nachbarn in der aktuellen Sortierung (stabil per ID). */
async function swapNeighborSortById(sortedList, rowId, delta, updateId) {
const sorted = [...sortedList].sort(bySortThenName)
const i = sorted.findIndex((x) => x.id === rowId)
const j = i + delta
if (i < 0 || j < 0 || j >= sorted.length) return
const a = sorted[i]
const b = sorted[j]
const oa = a.sort_order != null ? Number(a.sort_order) : (i + 1) * 10
const ob = b.sort_order != null ? Number(b.sort_order) : (j + 1) * 10
await updateId(a.id, { sort_order: ob })
await updateId(b.id, { sort_order: oa })
let oa = a.sort_order
let ob = b.sort_order
if (oa == null || oa === '') oa = (i + 1) * 10
else oa = Number(oa)
if (ob == null || ob === '') ob = (j + 1) * 10
else ob = Number(ob)
await Promise.all([
updateId(a.id, { sort_order: ob }),
updateId(b.id, { sort_order: oa })
])
}
function stop(e) {
e.preventDefault()
e.stopPropagation()
}
export default function SkillsCatalogAdmin() {
@ -66,6 +78,9 @@ export default function SkillsCatalogAdmin() {
const [newCategoryName, setNewCategoryName] = useState('')
const [newSkillName, setNewSkillName] = useState('')
/** Aktives Bearbeiten-Modal: Typ + Entitäts-ID */
const [editDialog, setEditDialog] = useState(null)
const refreshCategories = useCallback(async (mainId) => {
if (mainId == null) {
setCategories([])
@ -118,76 +133,59 @@ export default function SkillsCatalogAdmin() {
.sort(bySortThenName)
}, [catalog, selectedCategoryId])
const selectedMain = useMemo(
() => mains.find((m) => m.id === selectedMainId) || null,
[mains, selectedMainId]
)
const selectedCategory = useMemo(
() => categories.find((c) => c.id === selectedCategoryId) || null,
[categories, selectedCategoryId]
)
const selectedSkill = useMemo(
() => catalog.find((s) => s.id === selectedSkillId) || null,
[catalog, selectedSkillId]
)
useEffect(() => {
if (!selectedMain) {
setMainForm({ name: '', slug: '', description: '', sort_order: '' })
return
if (!editDialog) return
const onKey = (ev) => {
if (ev.key === 'Escape') setEditDialog(null)
}
document.addEventListener('keydown', onKey)
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = prev
}
}, [editDialog])
function openEditMain(m) {
setMainForm({
name: selectedMain.name || '',
slug: selectedMain.slug || '',
description: selectedMain.description || '',
sort_order: selectedMain.sort_order ?? ''
name: m.name || '',
slug: m.slug || '',
description: m.description || '',
sort_order: m.sort_order ?? ''
})
}, [selectedMain])
setEditDialog({ type: 'main', id: m.id })
}
useEffect(() => {
if (!selectedCategory) {
setCategoryForm({
name: '',
slug: '',
description: '',
main_category_id: '',
sort_order: ''
})
return
}
function openEditCategory(c) {
setCategoryForm({
name: selectedCategory.name || '',
slug: selectedCategory.slug || '',
description: selectedCategory.description || '',
main_category_id: selectedCategory.main_category_id ?? '',
sort_order: selectedCategory.sort_order ?? ''
name: c.name || '',
slug: c.slug || '',
description: c.description || '',
main_category_id: c.main_category_id ?? '',
sort_order: c.sort_order ?? ''
})
}, [selectedCategory])
setEditDialog({ type: 'category', id: c.id })
}
useEffect(() => {
if (!selectedSkill) {
setSkillForm({
name: '',
description: '',
category: '',
keywords: '',
status: 'active',
sort_order: '',
category_id: ''
})
return
}
function openEditSkill(s) {
setSkillForm({
name: selectedSkill.name || '',
description: selectedSkill.description || '',
category: selectedSkill.category || '',
keywords: selectedSkill.keywords || '',
status: selectedSkill.status || 'active',
sort_order: selectedSkill.sort_order ?? '',
category_id: selectedSkill.category_id ?? ''
name: s.name || '',
description: s.description || '',
category: s.category || '',
keywords: s.keywords || '',
status: s.status || 'active',
sort_order: s.sort_order ?? '',
category_id: s.category_id ?? ''
})
}, [selectedSkill])
setEditDialog({ type: 'skill', id: s.id })
}
function closeEditDialog() {
setEditDialog(null)
}
/** @returns {Promise<boolean>} true bei Erfolg */
async function run(op) {
setBusy(true)
setMessage('')
@ -196,8 +194,10 @@ export default function SkillsCatalogAdmin() {
await bootstrap()
if (selectedMainId) await refreshCategories(selectedMainId)
setMessage('Gespeichert.')
return true
} catch (e) {
setError(e.message || String(e))
return false
} finally {
setBusy(false)
}
@ -214,37 +214,34 @@ export default function SkillsCatalogAdmin() {
setSelectedSkillId(null)
}
async function handleSwapMain(row, delta) {
const idx = sortedMains.findIndex((m) => m.id === row.id)
async function handleSwapMain(rowId, delta) {
await run(async () => {
await swapNeighborSort(sortedMains, idx, delta, (id, data) =>
await swapNeighborSortById(sortedMains, rowId, delta, (id, data) =>
api.updateSkillMainCategory(id, data)
)
})
}
async function handleSwapCategory(row, delta) {
const idx = sortedCategories.findIndex((c) => c.id === row.id)
async function handleSwapCategory(rowId, delta) {
await run(async () => {
await swapNeighborSort(sortedCategories, idx, delta, (id, data) =>
await swapNeighborSortById(sortedCategories, rowId, delta, (id, data) =>
api.updateSkillCategory(id, data)
)
})
}
async function handleSwapSkill(row, delta) {
async function handleSwapSkill(rowId, delta) {
const sorted = [...skillsInCategory].sort(bySortThenName)
const idx = sorted.findIndex((s) => s.id === row.id)
await run(async () => {
await swapNeighborSort(sorted, idx, delta, (id, data) => api.updateSkill(id, data))
await swapNeighborSortById(sorted, rowId, delta, (id, data) => api.updateSkill(id, data))
})
}
async function handleSaveMain(e) {
e.preventDefault()
if (!selectedMainId) return
await run(async () => {
await api.updateSkillMainCategory(selectedMainId, {
if (!editDialog || editDialog.type !== 'main') return
const ok = await run(async () => {
await api.updateSkillMainCategory(editDialog.id, {
name: mainForm.name.trim(),
slug: (mainForm.slug || '').trim() || undefined,
description: mainForm.description || null,
@ -254,17 +251,18 @@ export default function SkillsCatalogAdmin() {
: Number(mainForm.sort_order)
})
})
if (ok) closeEditDialog()
}
async function handleSaveCategory(e) {
e.preventDefault()
if (!selectedCategoryId) return
if (!editDialog || editDialog.type !== 'category') return
const mid =
categoryForm.main_category_id === '' || categoryForm.main_category_id == null
? null
: Number(categoryForm.main_category_id)
await run(async () => {
await api.updateSkillCategory(selectedCategoryId, {
const ok = await run(async () => {
await api.updateSkillCategory(editDialog.id, {
name: categoryForm.name.trim(),
slug: (categoryForm.slug || '').trim() || undefined,
description: categoryForm.description || null,
@ -275,24 +273,28 @@ export default function SkillsCatalogAdmin() {
: Number(categoryForm.sort_order)
})
})
if (mid != null && mid !== selectedMainId) {
setSelectedMainId(mid)
setSelectedSkillId(null)
if (ok) {
if (mid != null && mid !== selectedMainId) {
setSelectedMainId(mid)
setSelectedSkillId(null)
}
closeEditDialog()
}
}
async function handleSaveSkill(e) {
e.preventDefault()
if (!selectedSkillId) return
if (!editDialog || editDialog.type !== 'skill') return
let cid =
skillForm.category_id === '' || skillForm.category_id == null
? null
: Number(skillForm.category_id)
if (cid == null && selectedSkill?.category_id) {
cid = selectedSkill.category_id
const skillRow = catalog.find((s) => s.id === editDialog.id)
if (cid == null && skillRow?.category_id) {
cid = skillRow.category_id
}
await run(async () => {
await api.updateSkill(selectedSkillId, {
const ok = await run(async () => {
await api.updateSkill(editDialog.id, {
name: skillForm.name.trim(),
description: skillForm.description || null,
category: skillForm.category || null,
@ -305,12 +307,15 @@ export default function SkillsCatalogAdmin() {
category_id: cid
})
})
if (cid != null && cid !== selectedCategoryId) {
const cat = allCategories.find((c) => c.id === cid)
if (cat?.main_category_id) {
setSelectedMainId(cat.main_category_id)
setSelectedCategoryId(cid)
if (ok) {
if (cid != null && cid !== selectedCategoryId) {
const cat = allCategories.find((c) => c.id === cid)
if (cat?.main_category_id) {
setSelectedMainId(cat.main_category_id)
setSelectedCategoryId(cid)
}
}
closeEditDialog()
}
}
@ -358,43 +363,45 @@ export default function SkillsCatalogAdmin() {
}
async function handleDeleteMain() {
if (!selectedMainId || !isSuperadmin) return
if (!editDialog || editDialog.type !== 'main' || !isSuperadmin) return
if (!window.confirm('Hauptkategorie wirklich löschen?')) return
await run(async () => {
await api.deleteSkillMainCategory(selectedMainId)
setSelectedMainId(null)
setSelectedCategoryId(null)
setSelectedSkillId(null)
const id = editDialog.id
const ok = await run(async () => {
await api.deleteSkillMainCategory(id)
if (selectedMainId === id) {
setSelectedMainId(null)
setSelectedCategoryId(null)
setSelectedSkillId(null)
}
})
if (ok) closeEditDialog()
}
async function handleDeleteCategory() {
if (!selectedCategoryId || !isSuperadmin) return
if (!editDialog || editDialog.type !== 'category' || !isSuperadmin) return
if (!window.confirm('Kategorie wirklich löschen?')) return
await run(async () => {
await api.deleteSkillCategory(selectedCategoryId)
setSelectedCategoryId(null)
setSelectedSkillId(null)
const id = editDialog.id
const ok = await run(async () => {
await api.deleteSkillCategory(id)
if (selectedCategoryId === id) {
setSelectedCategoryId(null)
setSelectedSkillId(null)
}
})
if (ok) closeEditDialog()
}
async function handleDeleteSkill() {
if (!selectedSkillId || !isSuperadmin) return
if (!editDialog || editDialog.type !== 'skill' || !isSuperadmin) return
if (!window.confirm('Fähigkeit wirklich löschen?')) return
await run(async () => {
await api.deleteSkill(selectedSkillId)
setSelectedSkillId(null)
const id = editDialog.id
const ok = await run(async () => {
await api.deleteSkill(id)
if (selectedSkillId === id) setSelectedSkillId(null)
})
if (ok) closeEditDialog()
}
const detailMode = selectedSkillId
? 'skill'
: selectedCategoryId
? 'category'
: selectedMainId
? 'main'
: 'none'
if (loading) {
return (
<div className="skills-catalog-admin">
@ -407,8 +414,8 @@ export default function SkillsCatalogAdmin() {
<div className="skills-catalog-admin">
<p className="skills-catalog-admin__intro muted">
Struktur: <strong>Hauptkategorie</strong> <strong>Kategorie</strong> {' '}
<strong>Fähigkeit</strong>. Reihenfolge mit den Pfeiltasten; Zuordnungen und Texte im
Bereich Bearbeiten.
<strong>Fähigkeit</strong>. Reihenfolge mit Pfeilen; Details über das Stift-Symbol (öffnet
ein Fenster auf dem iPhone nach unten scrollen, falls nötig).
</p>
{error ? (
@ -454,7 +461,11 @@ export default function SkillsCatalogAdmin() {
className="btn btn-secondary btn-tiny"
disabled={busy}
title="Nach oben"
onClick={() => handleSwapMain(m, -1)}
aria-label="Nach oben sortieren"
onClick={(e) => {
stop(e)
handleSwapMain(m.id, -1)
}}
>
</button>
@ -463,10 +474,27 @@ export default function SkillsCatalogAdmin() {
className="btn btn-secondary btn-tiny"
disabled={busy}
title="Nach unten"
onClick={() => handleSwapMain(m, 1)}
aria-label="Nach unten sortieren"
onClick={(e) => {
stop(e)
handleSwapMain(m.id, 1)
}}
>
</button>
<button
type="button"
className="btn btn-ghost btn-icon-touch"
disabled={busy}
title="Bearbeiten"
aria-label={`Hauptkategorie ${m.name} bearbeiten`}
onClick={(e) => {
stop(e)
openEditMain(m)
}}
>
</button>
</span>
</div>
</li>
@ -515,7 +543,11 @@ export default function SkillsCatalogAdmin() {
className="btn btn-secondary btn-tiny"
disabled={busy}
title="Nach oben"
onClick={() => handleSwapCategory(c, -1)}
aria-label="Nach oben sortieren"
onClick={(e) => {
stop(e)
handleSwapCategory(c.id, -1)
}}
>
</button>
@ -524,10 +556,27 @@ export default function SkillsCatalogAdmin() {
className="btn btn-secondary btn-tiny"
disabled={busy}
title="Nach unten"
onClick={() => handleSwapCategory(c, 1)}
aria-label="Nach unten sortieren"
onClick={(e) => {
stop(e)
handleSwapCategory(c.id, 1)
}}
>
</button>
<button
type="button"
className="btn btn-ghost btn-icon-touch"
disabled={busy}
title="Bearbeiten"
aria-label={`Kategorie ${c.name} bearbeiten`}
onClick={(e) => {
stop(e)
openEditCategory(c)
}}
>
</button>
</span>
</div>
</li>
@ -581,7 +630,11 @@ export default function SkillsCatalogAdmin() {
className="btn btn-secondary btn-tiny"
disabled={busy}
title="Nach oben"
onClick={() => handleSwapSkill(s, -1)}
aria-label="Nach oben sortieren"
onClick={(e) => {
stop(e)
handleSwapSkill(s.id, -1)
}}
>
</button>
@ -590,10 +643,27 @@ export default function SkillsCatalogAdmin() {
className="btn btn-secondary btn-tiny"
disabled={busy}
title="Nach unten"
onClick={() => handleSwapSkill(s, 1)}
aria-label="Nach unten sortieren"
onClick={(e) => {
stop(e)
handleSwapSkill(s.id, 1)
}}
>
</button>
<button
type="button"
className="btn btn-ghost btn-icon-touch"
disabled={busy}
title="Bearbeiten"
aria-label={`Fähigkeit ${s.name} bearbeiten`}
onClick={(e) => {
stop(e)
openEditSkill(s)
}}
>
</button>
</span>
</div>
</li>
@ -617,222 +687,279 @@ export default function SkillsCatalogAdmin() {
</section>
</div>
<section className="skills-catalog-detail" aria-label="Bearbeiten">
<h3 className="skills-catalog-detail__title">Bearbeiten</h3>
{detailMode === 'none' ? (
<p className="muted">
Wählen Sie eine Hauptkategorie, Kategorie oder Fähigkeit in den Spalten oben.
</p>
) : null}
{detailMode === 'main' ? (
<form className="skills-catalog-form" onSubmit={handleSaveMain}>
<h4 className="skills-catalog-form__heading">Hauptkategorie</h4>
<label className="form-label">Name</label>
<input
className="form-input"
value={mainForm.name}
onChange={(e) => setMainForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Slug</label>
<input
className="form-input"
value={mainForm.slug}
onChange={(e) => setMainForm((f) => ({ ...f, slug: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={3}
value={mainForm.description}
onChange={(e) => setMainForm((f) => ({ ...f, description: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Sortierung (Zahl, optional)</label>
<input
className="form-input"
type="number"
value={mainForm.sort_order}
onChange={(e) => setMainForm((f) => ({ ...f, sort_order: e.target.value }))}
disabled={busy}
/>
<div className="skills-catalog-form__actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
Speichern
{editDialog ? (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={closeEditDialog}
>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="skills-catalog-edit-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="skills-catalog-edit-title" className="admin-modal-sheet__title">
{editDialog.type === 'main'
? 'Hauptkategorie bearbeiten'
: editDialog.type === 'category'
? 'Kategorie bearbeiten'
: 'Fähigkeit bearbeiten'}
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
onClick={closeEditDialog}
aria-label="Schließen"
>
</button>
{isSuperadmin ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={handleDeleteMain}
>
Löschen
</button>
</div>
<div className="admin-modal-sheet__body">
{editDialog.type === 'main' ? (
<form className="skills-catalog-form" onSubmit={handleSaveMain}>
<label className="form-label">Name</label>
<input
className="form-input"
value={mainForm.name}
onChange={(e) => setMainForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
autoComplete="off"
/>
<label className="form-label">Slug</label>
<input
className="form-input"
value={mainForm.slug}
onChange={(e) => setMainForm((f) => ({ ...f, slug: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={3}
value={mainForm.description}
onChange={(e) => setMainForm((f) => ({ ...f, description: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Sortierung (Zahl, optional)</label>
<input
className="form-input"
type="number"
inputMode="numeric"
value={mainForm.sort_order}
onChange={(e) => setMainForm((f) => ({ ...f, sort_order: e.target.value }))}
disabled={busy}
/>
<div className="skills-catalog-form__actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
Speichern
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={closeEditDialog}
>
Abbrechen
</button>
{isSuperadmin ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={handleDeleteMain}
>
Löschen
</button>
) : null}
</div>
</form>
) : null}
{editDialog.type === 'category' ? (
<form className="skills-catalog-form" onSubmit={handleSaveCategory}>
<label className="form-label">Name</label>
<input
className="form-input"
value={categoryForm.name}
onChange={(e) => setCategoryForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Slug</label>
<input
className="form-input"
value={categoryForm.slug}
onChange={(e) => setCategoryForm((f) => ({ ...f, slug: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Hauptkategorie (verschieben)</label>
<select
className="form-input"
value={
categoryForm.main_category_id === '' ? '' : String(categoryForm.main_category_id)
}
onChange={(e) =>
setCategoryForm((f) => ({
...f,
main_category_id: e.target.value === '' ? '' : e.target.value
}))
}
disabled={busy}
>
<option value=""> keine </option>
{sortedMains.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={3}
value={categoryForm.description}
onChange={(e) => setCategoryForm((f) => ({ ...f, description: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Sortierung (optional)</label>
<input
className="form-input"
type="number"
inputMode="numeric"
value={categoryForm.sort_order}
onChange={(e) => setCategoryForm((f) => ({ ...f, sort_order: e.target.value }))}
disabled={busy}
/>
<div className="skills-catalog-form__actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
Speichern
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={closeEditDialog}
>
Abbrechen
</button>
{isSuperadmin ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={handleDeleteCategory}
>
Löschen
</button>
) : null}
</div>
</form>
) : null}
{editDialog.type === 'skill' ? (
<form className="skills-catalog-form" onSubmit={handleSaveSkill}>
<label className="form-label">Name</label>
<input
className="form-input"
value={skillForm.name}
onChange={(e) => setSkillForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Kategorie (verschieben)</label>
<select
className="form-input"
value={
skillForm.category_id === '' || skillForm.category_id == null
? ''
: String(skillForm.category_id)
}
onChange={(e) =>
setSkillForm((f) => ({
...f,
category_id: e.target.value === '' ? '' : e.target.value
}))
}
disabled={busy}
required
>
{allCategories.map((c) => (
<option key={c.id} value={c.id}>
{(c.main_category_name ? c.main_category_name + ' · ' : '') + c.name}
</option>
))}
</select>
<label className="form-label">Legacy-Kurzlabel category (optional)</label>
<input
className="form-input"
value={skillForm.category}
onChange={(e) => setSkillForm((f) => ({ ...f, category: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Stichwörter</label>
<input
className="form-input"
value={skillForm.keywords}
onChange={(e) => setSkillForm((f) => ({ ...f, keywords: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Status</label>
<select
className="form-input"
value={skillForm.status}
onChange={(e) => setSkillForm((f) => ({ ...f, status: e.target.value }))}
disabled={busy}
>
<option value="active">active</option>
<option value="inactive">inactive</option>
</select>
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={4}
value={skillForm.description}
onChange={(e) => setSkillForm((f) => ({ ...f, description: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Sortierung (optional)</label>
<input
className="form-input"
type="number"
inputMode="numeric"
value={skillForm.sort_order}
onChange={(e) => setSkillForm((f) => ({ ...f, sort_order: e.target.value }))}
disabled={busy}
/>
<div className="skills-catalog-form__actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
Speichern
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={closeEditDialog}
>
Abbrechen
</button>
{isSuperadmin ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={handleDeleteSkill}
>
Löschen
</button>
) : null}
</div>
</form>
) : null}
</div>
</form>
) : null}
{detailMode === 'category' ? (
<form className="skills-catalog-form" onSubmit={handleSaveCategory}>
<h4 className="skills-catalog-form__heading">Kategorie</h4>
<label className="form-label">Name</label>
<input
className="form-input"
value={categoryForm.name}
onChange={(e) => setCategoryForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Slug</label>
<input
className="form-input"
value={categoryForm.slug}
onChange={(e) => setCategoryForm((f) => ({ ...f, slug: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Hauptkategorie (verschieben)</label>
<select
className="form-input"
value={categoryForm.main_category_id === '' ? '' : String(categoryForm.main_category_id)}
onChange={(e) =>
setCategoryForm((f) => ({
...f,
main_category_id: e.target.value === '' ? '' : e.target.value
}))
}
disabled={busy}
>
<option value=""> keine </option>
{sortedMains.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={3}
value={categoryForm.description}
onChange={(e) => setCategoryForm((f) => ({ ...f, description: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Sortierung (optional)</label>
<input
className="form-input"
type="number"
value={categoryForm.sort_order}
onChange={(e) => setCategoryForm((f) => ({ ...f, sort_order: e.target.value }))}
disabled={busy}
/>
<div className="skills-catalog-form__actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
Speichern
</button>
{isSuperadmin ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={handleDeleteCategory}
>
Löschen
</button>
) : null}
</div>
</form>
) : null}
{detailMode === 'skill' ? (
<form className="skills-catalog-form" onSubmit={handleSaveSkill}>
<h4 className="skills-catalog-form__heading">Fähigkeit</h4>
<label className="form-label">Name</label>
<input
className="form-input"
value={skillForm.name}
onChange={(e) => setSkillForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Kategorie (verschieben)</label>
<select
className="form-input"
value={skillForm.category_id === '' || skillForm.category_id == null ? '' : String(skillForm.category_id)}
onChange={(e) =>
setSkillForm((f) => ({
...f,
category_id: e.target.value === '' ? '' : e.target.value
}))
}
disabled={busy}
required
>
{allCategories.map((c) => (
<option key={c.id} value={c.id}>
{(c.main_category_name ? c.main_category_name + ' · ' : '') + c.name}
</option>
))}
</select>
<label className="form-label">Legacy-Kurzlabel category (optional)</label>
<input
className="form-input"
value={skillForm.category}
onChange={(e) => setSkillForm((f) => ({ ...f, category: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Stichwörter</label>
<input
className="form-input"
value={skillForm.keywords}
onChange={(e) => setSkillForm((f) => ({ ...f, keywords: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Status</label>
<select
className="form-input"
value={skillForm.status}
onChange={(e) => setSkillForm((f) => ({ ...f, status: e.target.value }))}
disabled={busy}
>
<option value="active">active</option>
<option value="inactive">inactive</option>
</select>
<label className="form-label">Beschreibung</label>
<textarea
className="form-input"
rows={4}
value={skillForm.description}
onChange={(e) => setSkillForm((f) => ({ ...f, description: e.target.value }))}
disabled={busy}
/>
<label className="form-label">Sortierung (optional)</label>
<input
className="form-input"
type="number"
value={skillForm.sort_order}
onChange={(e) => setSkillForm((f) => ({ ...f, sort_order: e.target.value }))}
disabled={busy}
/>
<div className="skills-catalog-form__actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
Speichern
</button>
{isSuperadmin ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={handleDeleteSkill}
>
Löschen
</button>
) : null}
</div>
</form>
) : null}
</section>
</div>
</div>
) : null}
</div>
)
}