feat: Refactor analysis navigation to use category-based grouping and update state management
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 13s

This commit is contained in:
Lars 2026-04-05 10:06:29 +02:00
parent 6d0e2de66d
commit 630a3de88a
2 changed files with 90 additions and 178 deletions

View File

@ -291,82 +291,16 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
color: rgba(255, 255, 255, 0.88) !important;
}
/* Analyse „Analysen starten“: Unternavigation nach DB-Kategorie gruppiert */
.analysis-split__nav--grouped {
flex-direction: column;
overflow-x: visible;
overflow-y: auto;
padding-bottom: 0;
gap: 0;
max-height: min(70vh, 560px);
}
.analysis-split__nav-group {
margin-bottom: 14px;
}
.analysis-split__nav-group:last-child {
margin-bottom: 0;
}
.analysis-split__nav-group-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
.analysis-split__nav-cat-count {
margin-left: 6px;
font-size: 11px;
font-weight: 700;
color: var(--text3);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 6px 4px 10px;
border-bottom: 1px solid var(--border);
font-weight: 500;
opacity: 0.92;
}
.analysis-split__nav-group-count {
font-size: 10px;
font-weight: 600;
text-transform: none;
letter-spacing: 0;
color: var(--text3);
background: var(--surface2);
padding: 2px 7px;
border-radius: 8px;
}
.analysis-split__nav-group-items {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 10px;
}
.analysis-split__nav--grouped .analysis-split__nav-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
width: 100%;
white-space: normal;
text-align: left;
border-radius: 10px;
}
.analysis-split__nav-item-label {
flex: 1;
min-width: 0;
line-height: 1.35;
}
.analysis-split__nav-item-meta {
font-size: 11px;
font-weight: 400;
flex-shrink: 0;
line-height: 1.35;
}
.analysis-split__nav-item--active .analysis-split__nav-item-meta {
color: rgba(255, 255, 255, 0.9);
.analysis-split__nav-item--active .analysis-split__nav-cat-count {
color: rgba(255, 255, 255, 0.95);
opacity: 1;
}
.analysis-split__main {
@ -397,10 +331,6 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
gap: 8px;
}
.analysis-split__nav--grouped {
max-height: calc(100vh - 140px);
}
.analysis-split__nav-item {
width: 100%;
justify-content: flex-start;

View File

@ -45,11 +45,6 @@ function analysisCategoryLabel(key) {
return ANALYSIS_CATEGORY_LABELS[k] || String(key)
}
/** Statische DOM-Id für aria-labelledby (Kategorie kann Umlaute enthalten) */
function analysisNavGroupDomId(categoryKey) {
return `analysis-nav-cat-${String(categoryKey).replace(/[^a-zA-Z0-9_-]/g, '_')}`
}
/** Pipeline-Prompts nach `category` gruppieren (Backend-Feld), innerhalb Gruppe nach sort_order */
function buildPipelineGroups(pipelinePrompts) {
const m = new Map()
@ -343,7 +338,8 @@ export default function Analysis() {
const [tab, setTab] = useState('run')
const [newResult, setNewResult] = useState(null)
const [aiUsage, setAiUsage] = useState(null)
const [activePipelineSlug, setActivePipelineSlug] = useState(null)
/** Kategorie-Schlüssel aus `buildPipelineGroups` (Navigation); Detail = alle Pipelines dieser Kategorie */
const [activeCategoryKey, setActiveCategoryKey] = useState(null)
const [historyScopePick, setHistoryScopePick] = useState(null)
const loadAll = async () => {
@ -366,17 +362,22 @@ export default function Analysis() {
useEffect(() => {
const list = prompts.filter(p => p.active && p.type === 'pipeline')
setActivePipelineSlug(prev => {
setActiveCategoryKey(prev => {
if (!list.length) return null
if (prev && list.some(p => p.slug === prev)) return prev
const groups = buildPipelineGroups(list)
return groups[0]?.prompts[0]?.slug ?? list[0].slug
const keys = groups.map(g => g.categoryKey)
if (prev && keys.includes(prev)) return prev
return groups[0]?.categoryKey ?? null
})
}, [prompts])
useEffect(() => {
if (newResult?.scope) setActivePipelineSlug(newResult.scope)
}, [newResult?.scope])
if (!newResult?.scope) return
const list = prompts.filter(p => p.active && p.type === 'pipeline')
const groups = buildPipelineGroups(list)
const g = groups.find(gg => gg.prompts.some(p => p.slug === newResult.scope))
if (g) setActiveCategoryKey(g.categoryKey)
}, [newResult?.scope, prompts])
const runPrompt = async (slug) => {
setLoading(slug); setError(null); setNewResult(null)
@ -539,106 +540,87 @@ export default function Analysis() {
{canUseAI && pipelinePrompts.length > 0 && (
<>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
Analysen sind nach <strong>Kategorie</strong> gruppiert (Feld Kategorie beim Prompt, wie im Admin).
Wähle einen Eintrag; der Detailbereich erscheint auf dem Desktop rechts, auf schmalen Screens darunter.
Zuerst die <strong>Kategorie</strong> wählen (Chip-Leiste bzw. Seitenleiste). Alle Pipeline-Analysen
dieser Kategorie erscheinen im Detailbereich (rechts auf Desktop, darunter auf Mobil).
Kategorien kommen aus dem Feld Kategorie beim jeweiligen Prompt im Admin.
</p>
<div className="analysis-split">
<div className="analysis-split__nav-wrap">
<nav
className="analysis-split__nav analysis-split__nav--grouped"
aria-label="Verfügbare KI-Analysen nach Kategorie"
>
<nav className="analysis-split__nav" aria-label="KI-Analyse-Kategorien">
{pipelineGroups.map(({ categoryKey, label, prompts: inGroup }) => (
<div key={categoryKey} className="analysis-split__nav-group">
<div
className="analysis-split__nav-group-title"
id={analysisNavGroupDomId(categoryKey)}
>
{label}
<span className="analysis-split__nav-group-count">{inGroup.length}</span>
</div>
<div
className="analysis-split__nav-group-items"
role="group"
aria-labelledby={analysisNavGroupDomId(categoryKey)}
>
{inGroup.map(p => {
const existing = allInsights.find(i => i.scope === p.slug)
return (
<button
key={p.id}
type="button"
className={
'analysis-split__nav-item' +
(activePipelineSlug === p.slug ? ' analysis-split__nav-item--active' : '')
}
onClick={() => setActivePipelineSlug(p.slug)}
aria-current={activePipelineSlug === p.slug ? 'page' : undefined}
>
<span className="analysis-split__nav-item-label">
{p.display_name || SLUG_LABELS[p.slug] || p.name}
</span>
{existing && (
<span className="muted analysis-split__nav-item-meta">
{dayjs(existing.created).format('DD.MM.')}
</span>
)}
</button>
)
})}
</div>
</div>
<button
key={categoryKey}
type="button"
className={
'analysis-split__nav-item' +
(activeCategoryKey === categoryKey ? ' analysis-split__nav-item--active' : '')
}
onClick={() => setActiveCategoryKey(categoryKey)}
aria-current={activeCategoryKey === categoryKey ? 'page' : undefined}
>
{label}
<span className="analysis-split__nav-cat-count">({inGroup.length})</span>
</button>
))}
</nav>
</div>
<div className="analysis-split__main">
{activePipelineSlug && (() => {
const p = pipelinePrompts.find(x => x.slug === activePipelineSlug)
if (!p) return null
const existing = allInsights.find(i => i.scope === p.slug)
{activeCategoryKey && (() => {
const group = pipelineGroups.find(g => g.categoryKey === activeCategoryKey)
if (!group?.prompts?.length) return null
return (
<div key={p.id} className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
<div style={{display:'flex',alignItems:'flex-start',gap:12,flexWrap:'wrap'}}>
<div style={{flex:1,minWidth:0}}>
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
<span>{p.display_name || SLUG_LABELS[p.slug] || p.name}</span>
{aiUsage && <UsageBadge {...aiUsage} />}
</div>
{p.description && (
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
{p.description}
</div>
)}
{existing && (
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
Letzte Analyse: {dayjs(existing.created).format('DD.MM.YYYY, HH:mm')}
</div>
)}
</div>
<div
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{display:'inline-block'}}
>
<button
type="button"
className="btn btn-primary"
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={() => runPrompt(p.slug)}
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
>
{loading===p.slug
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> Starten</>}
</button>
</div>
<>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 12 }}>
{group.label} · {group.prompts.length} {group.prompts.length === 1 ? 'Analyse' : 'Analysen'}
</div>
{existing && newResult?.id !== existing.id && (
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false} prompts={prompts}/>
</div>
)}
</div>
{group.prompts.map(p => {
const existing = allInsights.find(i => i.scope === p.slug)
return (
<div key={p.id} className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
<div style={{display:'flex',alignItems:'flex-start',gap:12,flexWrap:'wrap'}}>
<div style={{flex:1,minWidth:0}}>
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
<span>{p.display_name || SLUG_LABELS[p.slug] || p.name}</span>
{aiUsage && <UsageBadge {...aiUsage} />}
</div>
{p.description && (
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
{p.description}
</div>
)}
{existing && (
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
Letzte Analyse: {dayjs(existing.created).format('DD.MM.YYYY, HH:mm')}
</div>
)}
</div>
<div
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{display:'inline-block'}}
>
<button
type="button"
className="btn btn-primary"
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={() => runPrompt(p.slug)}
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
>
{loading===p.slug
? <><div className="spinner" style={{width:13,height:13}}/> Läuft</>
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> Starten</>}
</button>
</div>
</div>
{existing && newResult?.id !== existing.id && (
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false} prompts={prompts}/>
</div>
)}
</div>
)
})}
</>
)
})()}
</div>