feat(iace-fmea): KI-Vorschlag Uebernehmen/Ablehnen flow + AP unit tests

Closes the loose end from IACE Phase 5 handover: the LLM FM-suggest button
existed and the backend endpoint was wired, but accepted suggestions had
no path into the FMEA worksheet.

Hook (useFMEA.ts):
- acceptSuggestion(fm, componentId): builds an FMEARow from FM defaults,
  prepends to rows (sorted by RPZ), removes the FM from suggestions.
  No-ops + drops the suggestion when (component, fm.id) is already in rows.
- rejectSuggestion(fmId): drops the FM from suggestions list.

Page (fmea/page.tsx):
- Suggestion cards now have explicit Uebernehmen / Ablehnen buttons.
- Counter "X Vorschlaege uebernommen" tracks accept count for the run.
- RPZ in each suggestion is colour-coded (red >200, orange >100).
- Hinweis line explains S/O/D adjustability after acceptance.
- acceptedCount auto-resets when suggesting starts or panel closes.

Tests (useFMEA.test.ts):
- 8 calculateAP cases covering AIAG-VDA 2019 boundary points for severity
  10 / 9 / 7 / 5 / 3, validating the H/M/L action priority matrix.

LOC: fmea/page.tsx hits 320 (soft target 300, well under 500 hard cap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-22 09:56:05 +02:00
parent 9bdaa28038
commit 872145d883
3 changed files with 144 additions and 18 deletions
@@ -1,6 +1,6 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import { useFMEA, type FMEARow } from './_hooks/useFMEA'
@@ -27,8 +27,17 @@ function rpzLabel(rpz: number): string {
export default function FMEAPage() {
const { projectId } = useParams<{ projectId: string }>()
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions } = useFMEA(projectId)
const { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions, acceptSuggestion, rejectSuggestion } = useFMEA(projectId)
const [suggestComp, setSuggestComp] = useState<string | null>(null)
const [acceptedCount, setAcceptedCount] = useState(0)
// Reset accepted-count when a fresh suggestion run is loaded or the panel closes.
useEffect(() => {
if (suggesting) setAcceptedCount(0)
}, [suggesting])
useEffect(() => {
if (suggestions.length === 0) setAcceptedCount(0)
}, [suggestions.length])
if (loading) {
return (
@@ -97,26 +106,60 @@ export default function FMEAPage() {
{suggestions.length > 0 && (
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
KI-Vorschlaege ({suggestions.length}) {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek'}
</h3>
<div>
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
KI-Vorschlaege ({suggestions.length}) {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek-Fallback'}
</h3>
{acceptedCount > 0 && (
<div className="text-xs text-green-700 dark:text-green-400 mt-0.5">
{acceptedCount} Vorschlag{acceptedCount > 1 ? 'e' : ''} uebernommen
</div>
)}
</div>
<button onClick={() => setSuggestions([])} className="text-xs text-purple-600 hover:text-purple-800">Schliessen</button>
</div>
<div className="space-y-2">
{suggestions.map((fm, i) => (
<div key={i} className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
<div className="flex gap-3 mt-1 text-xs text-gray-400">
<span>S={fm.default_severity}</span>
<span>O={fm.default_occurrence}</span>
<span>D={fm.default_detection}</span>
<span className="font-bold">RPZ={fm.default_severity * fm.default_occurrence * fm.default_detection}</span>
{suggestions.map((fm) => {
const rpz = fm.default_severity * fm.default_occurrence * fm.default_detection
return (
<div key={fm.id} className="flex items-start justify-between gap-3 bg-white dark:bg-gray-800 rounded-lg p-3 border border-purple-100 dark:border-purple-800">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
<div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
<div className="flex gap-3 mt-1 text-xs text-gray-400">
<span>S={fm.default_severity}</span>
<span>O={fm.default_occurrence}</span>
<span>D={fm.default_detection}</span>
<span className={`font-bold ${rpz > 200 ? 'text-red-600' : rpz > 100 ? 'text-orange-600' : 'text-gray-500'}`}>RPZ={rpz}</span>
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
onClick={() => {
if (!suggestComp) return
const ok = acceptSuggestion(fm, suggestComp)
if (ok) setAcceptedCount((c) => c + 1)
}}
disabled={!suggestComp}
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-medium rounded transition-colors"
title="Diesen Fehlermodus der FMEA-Tabelle hinzufuegen"
>
Uebernehmen
</button>
<button
onClick={() => rejectSuggestion(fm.id)}
className="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 text-xs font-medium rounded transition-colors"
title="Diesen Vorschlag verwerfen"
>
Ablehnen
</button>
</div>
</div>
</div>
))}
)
})}
</div>
<div className="text-[10px] text-purple-700 dark:text-purple-400 mt-3">
Hinweis: Uebernommene Fehlermodi erscheinen sofort in der Tabelle unten. Bewertung (S/O/D) ist anpassbar Standardwerte aus der Bibliothek.
</div>
</div>
)}