Gruppierung Analysen
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

This commit is contained in:
Lars 2026-04-05 09:04:08 +02:00
parent 1fa0edb3b5
commit 6d0e2de66d
2 changed files with 193 additions and 24 deletions

View File

@ -291,6 +291,84 @@ 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;
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);
}
.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__main {
min-width: 0;
}
@ -319,6 +397,10 @@ 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

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useMemo } from 'react'
import { Brain, Trash2, ChevronDown, ChevronUp, Target } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { api } from '../utils/api'
@ -14,6 +14,63 @@ const SLUG_LABELS = {
pipeline: '🔬 Mehrstufige Gesamtanalyse'
}
/** DB `ai_prompts.category` Reihenfolge der Gruppen in der Analyse-Navigation */
const ANALYSIS_CATEGORY_ORDER = ['körper', 'ernährung', 'training', 'schlaf', 'vitalwerte', 'ziele', 'ganzheitlich']
const ANALYSIS_CATEGORY_LABELS = {
körper: 'Körper',
ernährung: 'Ernährung',
training: 'Training',
schlaf: 'Schlaf',
vitalwerte: 'Vitalwerte',
ziele: 'Ziele',
ganzheitlich: 'Ganzheitlich',
}
function sortAnalysisCategoryKeys(keys) {
return [...keys].sort((a, b) => {
const na = String(a).toLowerCase()
const nb = String(b).toLowerCase()
const ia = ANALYSIS_CATEGORY_ORDER.indexOf(na)
const ib = ANALYSIS_CATEGORY_ORDER.indexOf(nb)
if (ia === -1 && ib === -1) return String(a).localeCompare(String(b), 'de')
if (ia === -1) return 1
if (ib === -1) return -1
return ia - ib
})
}
function analysisCategoryLabel(key) {
const k = String(key).toLowerCase()
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()
for (const p of pipelinePrompts) {
const raw =
p.category != null && String(p.category).trim() !== ''
? String(p.category).trim()
: 'ganzheitlich'
if (!m.has(raw)) m.set(raw, [])
m.get(raw).push(p)
}
for (const arr of m.values()) {
arr.sort((a, b) => (Number(a.sort_order) || 0) - (Number(b.sort_order) || 0))
}
return sortAnalysisCategoryKeys([...m.keys()]).map(cat => ({
categoryKey: cat,
label: analysisCategoryLabel(cat),
prompts: m.get(cat),
}))
}
function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) {
const [open, setOpen] = useState(defaultOpen)
@ -312,7 +369,8 @@ export default function Analysis() {
setActivePipelineSlug(prev => {
if (!list.length) return null
if (prev && list.some(p => p.slug === prev)) return prev
return list[0].slug
const groups = buildPipelineGroups(list)
return groups[0]?.prompts[0]?.slug ?? list[0].slug
})
}, [prompts])
@ -398,8 +456,11 @@ export default function Analysis() {
grouped[key].push(ins)
})
// Show only active pipeline-type prompts
const pipelinePrompts = prompts.filter(p => p.active && p.type === 'pipeline')
// Show only active pipeline-type prompts (und nach DB-Kategorie gruppiert)
const { pipelinePrompts, pipelineGroups } = useMemo(() => {
const list = prompts.filter(p => p.active && p.type === 'pipeline')
return { pipelinePrompts: list, pipelineGroups: buildPipelineGroups(list) }
}, [prompts])
const historyScopeKeys = Object.keys(grouped).sort((a, b) => a.localeCompare(b))
const activeHistoryScope =
@ -478,30 +539,56 @@ export default function Analysis() {
{canUseAI && pipelinePrompts.length > 0 && (
<>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
Wähle in der Liste eine Analyse; auf dem Desktop erscheint der Detailbereich rechts, auf schmalen Screens darunter.
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.
</p>
<div className="analysis-split">
<div className="analysis-split__nav-wrap">
<nav className="analysis-split__nav" aria-label="Verfügbare KI-Analysen">
{pipelinePrompts.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}
<nav
className="analysis-split__nav analysis-split__nav--grouped"
aria-label="Verfügbare KI-Analysen nach Kategorie"
>
{pipelineGroups.map(({ categoryKey, label, prompts: inGroup }) => (
<div key={categoryKey} className="analysis-split__nav-group">
<div
className="analysis-split__nav-group-title"
id={analysisNavGroupDomId(categoryKey)}
>
{p.display_name || SLUG_LABELS[p.slug] || p.name}
{existing && (
<span className="muted" style={{ fontSize: 11, fontWeight: 400 }}>
{' '}· {dayjs(existing.created).format('DD.MM.')}
</span>
)}
</button>
)
})}
{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>
))}
</nav>
</div>
<div className="analysis-split__main">