Compare commits

...

2 Commits

Author SHA1 Message Date
Benjamin Admin 872145d883 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>
2026-05-22 09:56:05 +02:00
Benjamin Admin 9bdaa28038 feat(ui): Branchen-Benchmark Sidebar-Link unter Compliance Agent (P107) 2026-05-22 09:50:41 +02:00
4 changed files with 145 additions and 18 deletions
@@ -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 // Get unique components for the suggest button
const components = [...new Map(rows.map((r) => [r.component.id, r.component])).values()] 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' 'use client'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import { useFMEA, type FMEARow } from './_hooks/useFMEA' import { useFMEA, type FMEARow } from './_hooks/useFMEA'
@@ -27,8 +27,17 @@ function rpzLabel(rpz: number): string {
export default function FMEAPage() { export default function FMEAPage() {
const { projectId } = useParams<{ projectId: string }>() 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 [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) { if (loading) {
return ( return (
@@ -97,26 +106,60 @@ export default function FMEAPage() {
{suggestions.length > 0 && ( {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="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"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300"> <div>
KI-Vorschlaege ({suggestions.length}) {suggestSource === 'llm' ? 'LLM-generiert' : 'Bibliothek'} <h3 className="text-sm font-semibold text-purple-800 dark:text-purple-300">
</h3> 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> <button onClick={() => setSuggestions([])} className="text-xs text-purple-600 hover:text-purple-800">Schliessen</button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{suggestions.map((fm, i) => ( {suggestions.map((fm) => {
<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"> const rpz = fm.default_severity * fm.default_occurrence * fm.default_detection
<div className="flex-1 min-w-0"> return (
<div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div> <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="text-xs text-gray-500 mt-0.5">{fm.effect}</div> <div className="flex-1 min-w-0">
<div className="flex gap-3 mt-1 text-xs text-gray-400"> <div className="text-sm font-medium text-gray-900 dark:text-white">{fm.name_de}</div>
<span>S={fm.default_severity}</span> <div className="text-xs text-gray-500 mt-0.5">{fm.effect}</div>
<span>O={fm.default_occurrence}</span> <div className="flex gap-3 mt-1 text-xs text-gray-400">
<span>D={fm.default_detection}</span> <span>S={fm.default_severity}</span>
<span className="font-bold">RPZ={fm.default_severity * fm.default_occurrence * fm.default_detection}</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> )
))} })}
</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>
</div> </div>
)} )}
@@ -73,6 +73,7 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side
<AdditionalModuleItem href="/sdk/ai-registration" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>} label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} /> <AdditionalModuleItem href="/sdk/ai-registration" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>} label="EU Registrierung" isActive={pathname?.startsWith('/sdk/ai-registration') ?? false} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/compliance-optimizer" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>} label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} /> <AdditionalModuleItem href="/sdk/compliance-optimizer" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" /></svg>} label="Compliance Optimizer" isActive={pathname?.startsWith('/sdk/compliance-optimizer') ?? false} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} /> <AdditionalModuleItem href="/sdk/agent" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>} label="Compliance Agent" isActive={pathname?.startsWith('/sdk/agent') ?? false} collapsed={collapsed} projectId={projectId} />
<AdditionalModuleItem href="/sdk/benchmark" icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>} label="Branchen-Benchmark" isActive={pathname?.startsWith('/sdk/benchmark') ?? false} collapsed={collapsed} projectId={projectId} />
</div> </div>
{/* CRA Compliance */} {/* CRA Compliance */}