Responsive Gui - partially Workflow #61

Merged
Lars merged 47 commits from develop into main 2026-04-05 11:27:44 +02:00
2 changed files with 90 additions and 178 deletions
Showing only changes of commit 630a3de88a - Show all commits

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; color: rgba(255, 255, 255, 0.88) !important;
} }
/* Analyse „Analysen starten“: Unternavigation nach DB-Kategorie gruppiert */ .analysis-split__nav-cat-count {
.analysis-split__nav--grouped { margin-left: 6px;
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-size: 11px;
font-weight: 700; font-weight: 500;
color: var(--text3); opacity: 0.92;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 6px 4px 10px;
border-bottom: 1px solid var(--border);
} }
.analysis-split__nav-group-count { .analysis-split__nav-item--active .analysis-split__nav-cat-count {
font-size: 10px; color: rgba(255, 255, 255, 0.95);
font-weight: 600; opacity: 1;
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 { .analysis-split__main {
@ -397,10 +331,6 @@ body { font-family: var(--font); background: var(--bg); color: var(--text1); -we
gap: 8px; gap: 8px;
} }
.analysis-split__nav--grouped {
max-height: calc(100vh - 140px);
}
.analysis-split__nav-item { .analysis-split__nav-item {
width: 100%; width: 100%;
justify-content: flex-start; justify-content: flex-start;

View File

@ -45,11 +45,6 @@ function analysisCategoryLabel(key) {
return ANALYSIS_CATEGORY_LABELS[k] || String(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 */ /** Pipeline-Prompts nach `category` gruppieren (Backend-Feld), innerhalb Gruppe nach sort_order */
function buildPipelineGroups(pipelinePrompts) { function buildPipelineGroups(pipelinePrompts) {
const m = new Map() const m = new Map()
@ -343,7 +338,8 @@ export default function Analysis() {
const [tab, setTab] = useState('run') const [tab, setTab] = useState('run')
const [newResult, setNewResult] = useState(null) const [newResult, setNewResult] = useState(null)
const [aiUsage, setAiUsage] = 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 [historyScopePick, setHistoryScopePick] = useState(null)
const loadAll = async () => { const loadAll = async () => {
@ -366,17 +362,22 @@ export default function Analysis() {
useEffect(() => { useEffect(() => {
const list = prompts.filter(p => p.active && p.type === 'pipeline') const list = prompts.filter(p => p.active && p.type === 'pipeline')
setActivePipelineSlug(prev => { setActiveCategoryKey(prev => {
if (!list.length) return null if (!list.length) return null
if (prev && list.some(p => p.slug === prev)) return prev
const groups = buildPipelineGroups(list) 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]) }, [prompts])
useEffect(() => { useEffect(() => {
if (newResult?.scope) setActivePipelineSlug(newResult.scope) if (!newResult?.scope) return
}, [newResult?.scope]) 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) => { const runPrompt = async (slug) => {
setLoading(slug); setError(null); setNewResult(null) setLoading(slug); setError(null); setNewResult(null)
@ -539,106 +540,87 @@ export default function Analysis() {
{canUseAI && pipelinePrompts.length > 0 && ( {canUseAI && pipelinePrompts.length > 0 && (
<> <>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}> <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). Zuerst die <strong>Kategorie</strong> wählen (Chip-Leiste bzw. Seitenleiste). Alle Pipeline-Analysen
Wähle einen Eintrag; der Detailbereich erscheint auf dem Desktop rechts, auf schmalen Screens darunter. dieser Kategorie erscheinen im Detailbereich (rechts auf Desktop, darunter auf Mobil).
Kategorien kommen aus dem Feld Kategorie beim jeweiligen Prompt im Admin.
</p> </p>
<div className="analysis-split"> <div className="analysis-split">
<div className="analysis-split__nav-wrap"> <div className="analysis-split__nav-wrap">
<nav <nav className="analysis-split__nav" aria-label="KI-Analyse-Kategorien">
className="analysis-split__nav analysis-split__nav--grouped"
aria-label="Verfügbare KI-Analysen nach Kategorie"
>
{pipelineGroups.map(({ categoryKey, label, prompts: inGroup }) => ( {pipelineGroups.map(({ categoryKey, label, prompts: inGroup }) => (
<div key={categoryKey} className="analysis-split__nav-group"> <button
<div key={categoryKey}
className="analysis-split__nav-group-title" type="button"
id={analysisNavGroupDomId(categoryKey)} className={
> 'analysis-split__nav-item' +
{label} (activeCategoryKey === categoryKey ? ' analysis-split__nav-item--active' : '')
<span className="analysis-split__nav-group-count">{inGroup.length}</span> }
</div> onClick={() => setActiveCategoryKey(categoryKey)}
<div aria-current={activeCategoryKey === categoryKey ? 'page' : undefined}
className="analysis-split__nav-group-items" >
role="group" {label}
aria-labelledby={analysisNavGroupDomId(categoryKey)} <span className="analysis-split__nav-cat-count">({inGroup.length})</span>
> </button>
{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> </nav>
</div> </div>
<div className="analysis-split__main"> <div className="analysis-split__main">
{activePipelineSlug && (() => { {activeCategoryKey && (() => {
const p = pipelinePrompts.find(x => x.slug === activePipelineSlug) const group = pipelineGroups.find(g => g.categoryKey === activeCategoryKey)
if (!p) return null if (!group?.prompts?.length) return null
const existing = allInsights.find(i => i.scope === p.slug)
return ( 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={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 12 }}>
<div style={{flex:1,minWidth:0}}> {group.label} · {group.prompts.length} {group.prompts.length === 1 ? 'Analyse' : 'Analysen'}
<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> </div>
{existing && newResult?.id !== existing.id && ( {group.prompts.map(p => {
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}> const existing = allInsights.find(i => i.scope === p.slug)
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false} prompts={prompts}/> return (
</div> <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> <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> </div>