shinkan-jinkendo/frontend/src/components/ProgressionFindingsPanel.jsx
Lars c1bf9279ad
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
Add Gap Offer Handling and UI Enhancements in Progression Graph Components
- 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.
2026-06-10 15:34:37 +02:00

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>
)
}