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:
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { calculateAP } from './useFMEA'
|
||||
|
||||
describe('calculateAP — AIAG-VDA 2019 Handbook Action Priority', () => {
|
||||
it('returns H for severity 10 with mid occurrence', () => {
|
||||
expect(calculateAP(10, 5, 5)).toBe('H')
|
||||
})
|
||||
|
||||
it('returns H for severity 9 with low detection', () => {
|
||||
expect(calculateAP(9, 4, 7)).toBe('H')
|
||||
})
|
||||
|
||||
it('returns M for severity 9 with low occurrence and good detection', () => {
|
||||
expect(calculateAP(9, 2, 5)).toBe('M')
|
||||
})
|
||||
|
||||
it('returns L for severity 9 with very low occurrence and detection', () => {
|
||||
expect(calculateAP(9, 1, 4)).toBe('L')
|
||||
})
|
||||
|
||||
it('returns H for severity 7 with high occurrence', () => {
|
||||
expect(calculateAP(7, 5, 1)).toBe('H')
|
||||
})
|
||||
|
||||
it('returns M for severity 7 with mid occurrence', () => {
|
||||
expect(calculateAP(7, 3, 5)).toBe('M')
|
||||
})
|
||||
|
||||
it('returns L for low-severity well-controlled mode', () => {
|
||||
expect(calculateAP(3, 1, 1)).toBe('L')
|
||||
})
|
||||
|
||||
it('returns L for severity 5 with very low occurrence and detection', () => {
|
||||
expect(calculateAP(5, 1, 1)).toBe('L')
|
||||
})
|
||||
})
|
||||
@@ -156,5 +156,52 @@ export function useFMEA(projectId: string) {
|
||||
// Get unique components for the suggest button
|
||||
const components = [...new Map(rows.map((r) => [r.component.id, r.component])).values()]
|
||||
|
||||
return { rows, loading, stats, components, suggestFMs, suggesting, suggestions, suggestSource, setSuggestions }
|
||||
/**
|
||||
* Accept a suggested FM: build an FMEA row from the FM defaults, prepend it
|
||||
* to the table state, and remove the FM from the suggestion list.
|
||||
* Returns false if the (component, fm.id) combo already exists in rows.
|
||||
*/
|
||||
function acceptSuggestion(fm: FailureMode, componentId: string): boolean {
|
||||
const comp = components.find((c) => c.id === componentId)
|
||||
if (!comp) return false
|
||||
const dup = rows.find((r) => r.component.id === componentId && r.failureMode.id === fm.id)
|
||||
if (dup) {
|
||||
// Still drop the suggestion so the UI does not keep offering it.
|
||||
setSuggestions((prev) => prev.filter((s) => s.id !== fm.id))
|
||||
return false
|
||||
}
|
||||
const s = fm.default_severity || 5
|
||||
const o = fm.default_occurrence || 5
|
||||
const d = fm.default_detection || 5
|
||||
const newRow: FMEARow = {
|
||||
component: comp,
|
||||
failureMode: fm,
|
||||
severity: s,
|
||||
occurrence: o,
|
||||
detection: d,
|
||||
rpz: s * o * d,
|
||||
ap: calculateAP(s, o, d),
|
||||
}
|
||||
setRows((prev) => [newRow, ...prev].sort((a, b) => b.rpz - a.rpz))
|
||||
setSuggestions((prev) => prev.filter((sg) => sg.id !== fm.id))
|
||||
return true
|
||||
}
|
||||
|
||||
function rejectSuggestion(fmId: string) {
|
||||
setSuggestions((prev) => prev.filter((sg) => sg.id !== fmId))
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
loading,
|
||||
stats,
|
||||
components,
|
||||
suggestFMs,
|
||||
suggesting,
|
||||
suggestions,
|
||||
suggestSource,
|
||||
setSuggestions,
|
||||
acceptSuggestion,
|
||||
rejectSuggestion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user