All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / k6 /health Baseline (push) Successful in 34s
Test Suite / playwright-tests (push) Successful in 1m18s
- Implemented `_build_evaluate_empty_slot_gap_specs` function to generate gap offer specifications for unfilled roadmap slots in evaluate-only mode. - Enhanced `ProgressionFindingsPanel` to display AI offers for empty slots and gaps, improving user interaction and clarity. - Updated `ProgressionGraphEditor` and `ProgressionSlotCard` components to support new functionalities for managing slots and offers. - Refactored utility functions in `progressionGraphDraft.js` to streamline slot management and offer handling. - Incremented application version to reflect these updates.
260 lines
8.4 KiB
JavaScript
260 lines
8.4 KiB
JavaScript
/**
|
|
* Zentrales Findings-Panel für Progressionsgraph-QA (Phase B.2).
|
|
*/
|
|
import React, { useMemo, useState } from 'react'
|
|
import {
|
|
offerCanExpandSlots,
|
|
offerNeedsNewSlot,
|
|
offerSourceLabel,
|
|
resolveOfferSlotIndex,
|
|
} from '../utils/progressionGraphDraft'
|
|
|
|
function severityStyle(pathQa) {
|
|
if (!pathQa) return {}
|
|
return {
|
|
background: pathQa.overall_ok
|
|
? 'color-mix(in srgb, var(--accent) 8%, var(--surface2))'
|
|
: 'color-mix(in srgb, var(--danger) 8%, var(--surface2))',
|
|
}
|
|
}
|
|
|
|
function GapOfferCard({
|
|
offer,
|
|
slotCount,
|
|
draft,
|
|
onApplyDraft,
|
|
onInsertSlot,
|
|
onGenerateAi,
|
|
generatingOfferId,
|
|
aiBusy,
|
|
}) {
|
|
const defaultSlot = resolveOfferSlotIndex(draft, offer)
|
|
const [slotPick, setSlotPick] = useState(
|
|
defaultSlot != null && Number.isFinite(defaultSlot) ? String(defaultSlot) : '',
|
|
)
|
|
const needsInsert = offerNeedsNewSlot(offer)
|
|
const canInsert = offerCanExpandSlots(draft, offer)
|
|
|
|
const slotOptions = useMemo(() => {
|
|
const rows = []
|
|
for (let i = 0; i < slotCount; i += 1) {
|
|
rows.push({ value: String(i), label: `Slot ${i + 1}` })
|
|
}
|
|
return rows
|
|
}, [slotCount])
|
|
|
|
const applyToSlot = () => {
|
|
const idx = slotPick !== '' ? Number(slotPick) : defaultSlot
|
|
if (!Number.isFinite(idx)) {
|
|
alert('Bitte einen Slot wählen.')
|
|
return
|
|
}
|
|
onApplyDraft(offer, idx)
|
|
}
|
|
|
|
return (
|
|
<li
|
|
style={{
|
|
padding: '10px 12px',
|
|
borderRadius: '8px',
|
|
border: '1px solid var(--border)',
|
|
background: 'var(--surface2)',
|
|
fontSize: '12px',
|
|
}}
|
|
>
|
|
<span className="exercise-tag" style={{ marginBottom: '6px', display: 'inline-block' }}>
|
|
{offerSourceLabel(offer.source)}
|
|
{offer.phase ? ` · ${offer.phase}` : ''}
|
|
{offer.has_ai_payload ? ' · KI-Entwurf bereit' : ''}
|
|
</span>
|
|
<div style={{ fontWeight: 600 }}>{offer.title_hint || offer.proposal_title || 'Übungsvorschlag'}</div>
|
|
{offer.rationale ? (
|
|
<p style={{ margin: '4px 0 0', color: 'var(--text2)' }}>{offer.rationale}</p>
|
|
) : null}
|
|
{offer.from_title && offer.to_title ? (
|
|
<p style={{ margin: '4px 0 0', color: 'var(--text3)', fontSize: '11px' }}>
|
|
Zwischen „{offer.from_title}“ und „{offer.to_title}“
|
|
</p>
|
|
) : null}
|
|
|
|
<div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
|
<label style={{ fontSize: '11px', color: 'var(--text3)' }}>
|
|
Ziel-Slot
|
|
<select
|
|
className="form-input"
|
|
style={{ marginLeft: '6px', padding: '4px 8px', fontSize: '12px', minWidth: '100px' }}
|
|
value={slotPick}
|
|
onChange={(e) => setSlotPick(e.target.value)}
|
|
>
|
|
<option value="">— wählen —</option>
|
|
{slotOptions.map((o) => (
|
|
<option key={o.value} value={o.value}>
|
|
{o.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div style={{ marginTop: '8px', display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
|
|
{offer.has_ai_payload ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
style={{ fontSize: '11px', padding: '4px 8px' }}
|
|
onClick={() => applyToSlot()}
|
|
>
|
|
Entwurf in Slot
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
style={{ fontSize: '11px', padding: '4px 8px' }}
|
|
disabled={aiBusy}
|
|
onClick={() => onGenerateAi(offer, slotPick !== '' ? Number(slotPick) : defaultSlot)}
|
|
>
|
|
{generatingOfferId === offer.offer_id ? 'KI erstellt…' : 'KI anlegen'}
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{ fontSize: '11px', padding: '4px 8px' }}
|
|
onClick={() => applyToSlot()}
|
|
>
|
|
Platzhalter in Slot
|
|
</button>
|
|
{needsInsert ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary"
|
|
style={{ fontSize: '11px', padding: '4px 8px' }}
|
|
disabled={!canInsert}
|
|
title={!canInsert ? `Maximal ${slotCount} Slots` : 'Neuen Slot zwischen zwei Stufen einfügen'}
|
|
onClick={() => onInsertSlot(offer)}
|
|
>
|
|
Neuen Slot einfügen
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</li>
|
|
)
|
|
}
|
|
|
|
export default function ProgressionFindingsPanel({
|
|
pathQa = null,
|
|
gapFillOffers = [],
|
|
draft = null,
|
|
slotCount = 0,
|
|
loading = false,
|
|
error = '',
|
|
onEvaluate,
|
|
onApplyGapOffer,
|
|
onInsertGapSlot,
|
|
onGenerateGapAi,
|
|
generatingOfferId = null,
|
|
aiBusy = false,
|
|
evaluateDisabled = false,
|
|
}) {
|
|
return (
|
|
<div className="card" style={{ position: 'sticky', top: '12px' }}>
|
|
<h3 style={{ marginTop: 0, fontSize: '1rem' }}>Graph-Bewertung</h3>
|
|
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, lineHeight: 1.45 }}>
|
|
Prüft den Slot-Stand und listet KI-Angebote für leere Stufen und Lücken.
|
|
</p>
|
|
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary btn-full"
|
|
disabled={loading || evaluateDisabled}
|
|
onClick={onEvaluate}
|
|
style={{ marginBottom: '12px' }}
|
|
>
|
|
{loading ? 'Bewertung läuft…' : 'Graph bewerten'}
|
|
</button>
|
|
|
|
{error ? (
|
|
<p className="form-error" style={{ marginTop: 0 }}>
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
|
|
{pathQa ? (
|
|
<div
|
|
style={{
|
|
padding: '10px 12px',
|
|
borderRadius: '8px',
|
|
fontSize: '12px',
|
|
lineHeight: 1.45,
|
|
...severityStyle(pathQa),
|
|
}}
|
|
>
|
|
<strong>
|
|
Pfad-QS: {pathQa.overall_ok ? 'OK' : 'Hinweise'}
|
|
{pathQa.quality_score != null
|
|
? ` (${Math.round(Number(pathQa.quality_score) * 100)} %)`
|
|
: ''}
|
|
</strong>
|
|
{pathQa.topic_coverage ? (
|
|
<p style={{ margin: '6px 0 0', color: 'var(--text2)' }}>{pathQa.topic_coverage}</p>
|
|
) : null}
|
|
{Array.isArray(pathQa.issues) && pathQa.issues.length > 0 ? (
|
|
<ul style={{ margin: '6px 0 0', paddingLeft: '16px', color: 'var(--text2)' }}>
|
|
{pathQa.issues.map((issue) => (
|
|
<li key={issue}>{issue}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
{Array.isArray(pathQa.recommendations) && pathQa.recommendations.length > 0 ? (
|
|
<>
|
|
<p style={{ margin: '8px 0 4px', fontWeight: 600, color: 'var(--text2)' }}>Empfehlungen</p>
|
|
<ul style={{ margin: 0, paddingLeft: '16px', color: 'var(--text2)' }}>
|
|
{pathQa.recommendations.map((rec) => (
|
|
<li key={rec}>{rec}</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
) : null}
|
|
{Number(pathQa.off_topic_count) > 0 ? (
|
|
<p style={{ margin: '6px 0 0', color: 'var(--danger)' }}>
|
|
{pathQa.off_topic_count} Schritt(e) ohne Bezug zum Pfad-Thema.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : (
|
|
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
|
|
Noch keine Bewertung. Roadmap anlegen, dann „Graph bewerten“ oder „Übungen matchen“.
|
|
</p>
|
|
)}
|
|
|
|
<div style={{ marginTop: '14px' }}>
|
|
<h4 style={{ margin: '0 0 8px', fontSize: '0.9rem' }}>
|
|
KI-Angebote {gapFillOffers.length > 0 ? `(${gapFillOffers.length})` : ''}
|
|
</h4>
|
|
{gapFillOffers.length === 0 ? (
|
|
<p style={{ fontSize: '12px', color: 'var(--text3)', margin: 0 }}>
|
|
Keine offenen Angebote. Nach Match oder Bewertung erscheinen Vorschläge für leere Slots und Lücken.
|
|
</p>
|
|
) : (
|
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
|
{gapFillOffers.map((offer) => (
|
|
<GapOfferCard
|
|
key={offer.offer_id || `${offer.source}-${offer.title_hint}`}
|
|
offer={offer}
|
|
slotCount={slotCount}
|
|
draft={draft}
|
|
generatingOfferId={generatingOfferId}
|
|
aiBusy={aiBusy}
|
|
onApplyDraft={(o, idx) => onApplyGapOffer(o, idx)}
|
|
onInsertSlot={onInsertGapSlot}
|
|
onGenerateAi={onGenerateGapAi}
|
|
/>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|