refactor(admin): split controls, training, control-provenance, iace/verification pages
Each page.tsx exceeded the 500-LOC hard cap. Extracted components and hooks into colocated _components/ and _hooks/ directories; page.tsx is now a thin orchestrator. - controls/page.tsx: 944 → 180 LOC; extracted ControlCard, AddControlForm, LoadingSkeleton, TransitionErrorBanner, StatsCards, FilterBar, RAGPanel into _components/ and useControlsData, useRAGSuggestions into _hooks/; types into _types.ts - training/page.tsx: 780 → 288 LOC; extracted ContentTab (inline content generator tab) into _components/ContentTab.tsx - control-provenance/page.tsx: 739 → 122 LOC; extracted MarkdownRenderer, UsageBadge, PermBadge, LicenseMatrix, SourceRegistry into _components/; PROVENANCE_SECTIONS static data into _data/provenance-sections.ts - iace/[projectId]/verification/page.tsx: 673 → 196 LOC; extracted StatusBadge, VerificationForm, CompleteModal, SuggestEvidenceModal, VerificationTable into _components/ Zero behavior changes; logic relocated verbatim. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
import { UsageBadge } from './UsageBadge'
|
||||||
|
|
||||||
|
interface LicenseInfo {
|
||||||
|
license_id: string
|
||||||
|
name: string
|
||||||
|
terms_url: string | null
|
||||||
|
commercial_use: string
|
||||||
|
ai_training_restriction: string | null
|
||||||
|
tdm_allowed_under_44b: string | null
|
||||||
|
deletion_required: boolean
|
||||||
|
notes: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LicenseMatrix({ licenses, loading }: { licenses: LicenseInfo[]; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">Lizenz-Matrix</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">Uebersicht aller Lizenzen mit ihren erlaubten Nutzungsarten.</p>
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-pulse h-32 bg-gray-100 rounded" />
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50">
|
||||||
|
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Lizenz</th>
|
||||||
|
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Kommerziell</th>
|
||||||
|
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">AI-Training</th>
|
||||||
|
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">TDM (§44b)</th>
|
||||||
|
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Loeschpflicht</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{licenses.map(lic => (
|
||||||
|
<tr key={lic.license_id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-2 border-b">
|
||||||
|
<div className="font-medium text-gray-900">{lic.license_id}</div>
|
||||||
|
<div className="text-xs text-gray-500">{lic.name}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 border-b"><UsageBadge value={lic.commercial_use} /></td>
|
||||||
|
<td className="px-3 py-2 border-b"><UsageBadge value={lic.ai_training_restriction || 'n/a'} /></td>
|
||||||
|
<td className="px-3 py-2 border-b"><UsageBadge value={lic.tdm_allowed_under_44b || 'unclear'} /></td>
|
||||||
|
<td className="px-3 py-2 border-b">
|
||||||
|
{lic.deletion_required
|
||||||
|
? <span className="text-red-600 text-xs font-medium">Ja</span>
|
||||||
|
: <span className="text-green-600 text-xs font-medium">Nein</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export function MarkdownRenderer({ content }: { content: string }) {
|
||||||
|
let html = content
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
|
||||||
|
html = html.replace(
|
||||||
|
/^```[\w]*\n([\s\S]*?)^```$/gm,
|
||||||
|
(_m, code: string) => `<pre class="bg-gray-50 border rounded p-3 my-3 text-xs font-mono overflow-x-auto whitespace-pre">${code.trimEnd()}</pre>`
|
||||||
|
)
|
||||||
|
|
||||||
|
html = html.replace(
|
||||||
|
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
|
||||||
|
(_m, header: string, _sep: string, body: string) => {
|
||||||
|
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) =>
|
||||||
|
`<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`
|
||||||
|
).join('')
|
||||||
|
const rows = body.trim().split('\n').map((row: string) => {
|
||||||
|
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) =>
|
||||||
|
`<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`
|
||||||
|
).join('')
|
||||||
|
return `<tr>${tds}</tr>`
|
||||||
|
}).join('')
|
||||||
|
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
|
||||||
|
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
|
||||||
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code class="bg-gray-100 px-1 py-0.5 rounded text-xs font-mono">$1</code>')
|
||||||
|
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
|
||||||
|
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
|
||||||
|
html = html.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-decimal">$2</li>')
|
||||||
|
html = html.replace(/^(?!<[hultdp]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
|
||||||
|
|
||||||
|
return <div dangerouslySetInnerHTML={{ __html: html }} />
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { ExternalLink } from 'lucide-react'
|
||||||
|
import { PermBadge } from './UsageBadge'
|
||||||
|
|
||||||
|
interface SourceInfo {
|
||||||
|
source_id: string
|
||||||
|
title: string
|
||||||
|
publisher: string
|
||||||
|
url: string | null
|
||||||
|
version_label: string | null
|
||||||
|
language: string
|
||||||
|
license_id: string
|
||||||
|
license_name: string
|
||||||
|
commercial_use: string
|
||||||
|
allowed_analysis: boolean
|
||||||
|
allowed_store_excerpt: boolean
|
||||||
|
allowed_ship_embeddings: boolean
|
||||||
|
allowed_ship_in_product: boolean
|
||||||
|
vault_retention_days: number
|
||||||
|
vault_access_tier: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SourceRegistry({ sources, loading }: { sources: SourceInfo[]; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">Quellenregister</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">Alle registrierten Quellen mit ihren Berechtigungen.</p>
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-pulse h-32 bg-gray-100 rounded" />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sources.map(src => (
|
||||||
|
<div key={src.source_id} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">{src.title}</h3>
|
||||||
|
<p className="text-xs text-gray-500">{src.publisher} — {src.license_name}</p>
|
||||||
|
</div>
|
||||||
|
{src.url && (
|
||||||
|
<a href={src.url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800">
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
Quelle
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-2">
|
||||||
|
<PermBadge label="Analyse" allowed={src.allowed_analysis} />
|
||||||
|
<PermBadge label="Excerpt" allowed={src.allowed_store_excerpt} />
|
||||||
|
<PermBadge label="Embeddings" allowed={src.allowed_ship_embeddings} />
|
||||||
|
<PermBadge label="Produkt" allowed={src.allowed_ship_in_product} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { CheckCircle2, Lock } from 'lucide-react'
|
||||||
|
|
||||||
|
const USAGE_CONFIG: Record<string, { bg: string; label: string }> = {
|
||||||
|
allowed: { bg: 'bg-green-100 text-green-800', label: 'Erlaubt' },
|
||||||
|
restricted: { bg: 'bg-yellow-100 text-yellow-800', label: 'Eingeschraenkt' },
|
||||||
|
prohibited: { bg: 'bg-red-100 text-red-800', label: 'Verboten' },
|
||||||
|
unclear: { bg: 'bg-gray-100 text-gray-600', label: 'Unklar' },
|
||||||
|
yes: { bg: 'bg-green-100 text-green-800', label: 'Ja' },
|
||||||
|
no: { bg: 'bg-red-100 text-red-800', label: 'Nein' },
|
||||||
|
'n/a': { bg: 'bg-gray-100 text-gray-400', label: 'k.A.' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsageBadge({ value }: { value: string }) {
|
||||||
|
const c = USAGE_CONFIG[value] || USAGE_CONFIG.unclear
|
||||||
|
return <span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermBadge({ label, allowed }: { label: string; allowed: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{allowed ? <CheckCircle2 className="w-3 h-3 text-green-500" /> : <Lock className="w-3 h-3 text-red-400" />}
|
||||||
|
<span className={`text-xs ${allowed ? 'text-green-700' : 'text-red-500'}`}>{label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
export interface ProvenanceSection {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROVENANCE_SECTIONS: ProvenanceSection[] = [
|
||||||
|
{
|
||||||
|
id: 'methodology',
|
||||||
|
title: 'Methodik der Control-Erstellung',
|
||||||
|
content: `## Unabhaengige Formulierung
|
||||||
|
|
||||||
|
Alle Controls in der Canonical Control Library wurden **eigenstaendig formuliert** und folgen einer
|
||||||
|
**unabhaengigen Taxonomie**. Es werden keine proprietaeren Bezeichner, Nummern oder Strukturen
|
||||||
|
aus geschuetzten Quellen uebernommen.
|
||||||
|
|
||||||
|
### Dreistufiger Prozess
|
||||||
|
|
||||||
|
1. **Offene Recherche** — Identifikation von Security-Anforderungen aus oeffentlichen, frei zugaenglichen
|
||||||
|
Frameworks (OWASP, NIST, ENISA). Jede Anforderung wird aus mindestens 2 unabhaengigen offenen Quellen belegt.
|
||||||
|
|
||||||
|
2. **Eigenstaendige Formulierung** — Jedes Control wird mit eigener Sprache, eigener Struktur und eigener
|
||||||
|
Taxonomie (z.B. AUTH-001, NET-001) verfasst. Kein Copy-Paste, keine Paraphrase geschuetzter Texte.
|
||||||
|
|
||||||
|
3. **Too-Close-Pruefung** — Automatisierte Aehnlichkeitspruefung gegen Quelltexte mit 5 Metriken
|
||||||
|
(Token Overlap, N-Gram Jaccard, Embedding Cosine, LCS Ratio, Exact-Phrase). Nur Controls mit
|
||||||
|
Status PASS oder WARN (+ Human Review) werden freigegeben.
|
||||||
|
|
||||||
|
### Rechtliche Grundlage
|
||||||
|
|
||||||
|
- **UrhG §44b** — Text & Data Mining erlaubt fuer Analyse; Kopien werden danach geloescht
|
||||||
|
- **UrhG §23** — Hinreichender Abstand zum Originalwerk durch eigene Formulierung
|
||||||
|
- **BSI Nutzungsbedingungen** — Kommerzielle Nutzung nur mit Zustimmung; wir nutzen BSI-Dokumente
|
||||||
|
ausschliesslich als Analysegrundlage, nicht im Produkt`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'filters',
|
||||||
|
title: 'Filter in der Control Library',
|
||||||
|
content: `## Dropdown-Filter
|
||||||
|
|
||||||
|
Die Control Library bietet 7 Filter-Dropdowns, um die ueber 3.000 Controls effizient zu durchsuchen:
|
||||||
|
|
||||||
|
### Schweregrad (Severity)
|
||||||
|
|
||||||
|
| Stufe | Farbe | Bedeutung |
|
||||||
|
|-------|-------|-----------|
|
||||||
|
| **Kritisch** | Rot | Sicherheitskritische Controls — Verstoesse fuehren zu schwerwiegenden Risiken |
|
||||||
|
| **Hoch** | Orange | Wichtige Controls — sollten zeitnah umgesetzt werden |
|
||||||
|
| **Mittel** | Gelb | Standardmaessige Controls — empfohlene Umsetzung |
|
||||||
|
| **Niedrig** | Gruen | Nice-to-have Controls — zusaetzliche Haertung |
|
||||||
|
|
||||||
|
### Domain
|
||||||
|
|
||||||
|
Das Praefix der Control-ID (z.B. \`AUTH-001\`, \`SEC-042\`). Kennzeichnet den thematischen Bereich.
|
||||||
|
Die haeufigsten Domains:
|
||||||
|
|
||||||
|
| Domain | Anzahl | Thema |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| SEC | ~700 | Allgemeine Sicherheit, Systemhaertung |
|
||||||
|
| COMP | ~470 | Compliance, Regulierung, Nachweispflichten |
|
||||||
|
| DATA | ~400 | Datenschutz, Datenklassifizierung, DSGVO |
|
||||||
|
| AI | ~290 | KI-Regulierung (AI Act, Transparenz, Erklaerbarkeit) |
|
||||||
|
| LOG | ~230 | Logging, Monitoring, SIEM |
|
||||||
|
| AUTH | ~200 | Authentifizierung, Zugriffskontrolle |
|
||||||
|
| NET | ~150 | Netzwerksicherheit, Transport, Firewall |
|
||||||
|
| CRYP | ~90 | Kryptographie, Schluesselmanagement |
|
||||||
|
| ACC | ~25 | Zugriffskontrolle (Access Control) |
|
||||||
|
| INC | ~25 | Incident Response, Vorfallmanagement |
|
||||||
|
|
||||||
|
Zusaetzlich existieren spezialisierte Domains wie CRA, ARC (Architektur), API, PKI, SUP (Supply Chain) u.v.m.
|
||||||
|
|
||||||
|
### Status (Release State)
|
||||||
|
|
||||||
|
| Status | Bedeutung |
|
||||||
|
|--------|-----------|
|
||||||
|
| **Draft** | Entwurf — noch nicht freigegeben |
|
||||||
|
| **Approved** | Freigegeben fuer Kunden |
|
||||||
|
| **Review noetig** | Muss manuell geprueft werden |
|
||||||
|
| **Zu aehnlich** | Too-Close-Check hat Warnung ausgeloest |
|
||||||
|
| **Duplikat** | Wurde als Duplikat eines anderen Controls erkannt |
|
||||||
|
|
||||||
|
### Nachweis (Verification Method)
|
||||||
|
|
||||||
|
| Methode | Farbe | Beschreibung |
|
||||||
|
|---------|-------|-------------|
|
||||||
|
| **Code Review** | Blau | Nachweis durch Quellcode-Inspektion |
|
||||||
|
| **Dokument** | Amber | Nachweis durch Richtlinien, Prozesse, Schulungen |
|
||||||
|
| **Tool** | Teal | Nachweis durch automatisierte Scans/Monitoring |
|
||||||
|
| **Hybrid** | Lila | Kombination aus mehreren Methoden |
|
||||||
|
|
||||||
|
### Kategorie
|
||||||
|
|
||||||
|
Thematische Einordnung (17 Kategorien). Kategorien sind **thematisch**, Domains **strukturell**.
|
||||||
|
Ein AUTH-Control kann z.B. die Kategorie "Netzwerksicherheit" haben.
|
||||||
|
|
||||||
|
### Zielgruppe (Target Audience)
|
||||||
|
|
||||||
|
| Zielgruppe | Bedeutung |
|
||||||
|
|------------|-----------|
|
||||||
|
| **Unternehmen** | Fuer Endkunden/Firmen relevant |
|
||||||
|
| **Behoerden** | Spezifisch fuer oeffentliche Verwaltung |
|
||||||
|
| **Anbieter** | Fuer SaaS/Plattform-Anbieter |
|
||||||
|
| **Alle** | Allgemein anwendbar |
|
||||||
|
|
||||||
|
### Dokumentenursprung (Source)
|
||||||
|
|
||||||
|
Filtert nach der Quelldokument-Herkunft des Controls. Zeigt alle Quellen sortiert nach
|
||||||
|
Haeufigkeit. Die wichtigsten Quellen:
|
||||||
|
|
||||||
|
| Quelle | Typ |
|
||||||
|
|--------|-----|
|
||||||
|
| KI-Verordnung (EU) 2024/1689 | EU-Recht |
|
||||||
|
| Cyber Resilience Act (EU) 2024/2847 | EU-Recht |
|
||||||
|
| DSGVO (EU) 2016/679 | EU-Recht |
|
||||||
|
| NIS2-Richtlinie (EU) 2022/2555 | EU-Recht |
|
||||||
|
| NIST SP 800-53, CSF 2.0, SSDF | US-Standards |
|
||||||
|
| OWASP Top 10, ASVS, SAMM | Open Source |
|
||||||
|
| ENISA Guidelines | EU-Agentur |
|
||||||
|
| CISA Secure by Design | US-Behoerde |
|
||||||
|
| BDSG, TKG, GewO, HGB | Deutsche Gesetze |
|
||||||
|
| EDPB Leitlinien | EU Datenschutz |`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'badges',
|
||||||
|
title: 'Badges & Lizenzregeln',
|
||||||
|
content: `## Badges in der Control Library
|
||||||
|
|
||||||
|
Jedes Control zeigt mehrere farbige Badges:
|
||||||
|
|
||||||
|
### Lizenzregel-Badge (Rule 1 / 2 / 3)
|
||||||
|
|
||||||
|
Die Lizenzregel bestimmt, wie ein Control erstellt und genutzt werden darf:
|
||||||
|
|
||||||
|
| Badge | Farbe | Regel | Bedeutung |
|
||||||
|
|-------|-------|-------|-----------|
|
||||||
|
| **Free Use** | Gruen | Rule 1 | Quelle ist Public Domain oder EU-Recht — Originaltext darf gezeigt werden |
|
||||||
|
| **Zitation** | Blau | Rule 2 | Quelle ist CC-BY oder aehnlich — Zitation + Quellenangabe erforderlich |
|
||||||
|
| **Reformuliert** | Amber | Rule 3 | Quelle hat eingeschraenkte Lizenz — Control wurde eigenstaendig reformuliert, kein Originaltext |
|
||||||
|
|
||||||
|
### Processing-Path
|
||||||
|
|
||||||
|
| Pfad | Bedeutung |
|
||||||
|
|------|-----------|
|
||||||
|
| **structured** | Control wurde direkt aus strukturierten Daten (Tabellen, Listen) extrahiert |
|
||||||
|
| **llm_reform** | Control wurde mit LLM eigenstaendig formuliert (bei Rule 3 zwingend) |
|
||||||
|
|
||||||
|
### Referenzen (Open Anchors)
|
||||||
|
|
||||||
|
Zeigt die Anzahl der verlinkten Open-Source-Referenzen (OWASP, NIST, ENISA etc.).
|
||||||
|
Jedes freigegebene Control muss mindestens 1 Open Anchor haben.
|
||||||
|
|
||||||
|
### Weitere Badges
|
||||||
|
|
||||||
|
| Badge | Bedeutung |
|
||||||
|
|-------|-----------|
|
||||||
|
| Score | Risiko-Score (0-10) |
|
||||||
|
| Severity-Badge | Schweregrad (Kritisch/Hoch/Mittel/Niedrig) |
|
||||||
|
| State-Badge | Freigabestatus (Draft/Approved/etc.) |
|
||||||
|
| Kategorie-Badge | Thematische Kategorie |
|
||||||
|
| Zielgruppe-Badge | Enterprise/Behoerden/Anbieter/Alle |`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'taxonomy',
|
||||||
|
title: 'Unabhaengige Taxonomie',
|
||||||
|
content: `## Eigenes Klassifikationssystem
|
||||||
|
|
||||||
|
Die Canonical Control Library verwendet ein **eigenes Domain-Schema**, das sich bewusst von
|
||||||
|
proprietaeren Frameworks unterscheidet. Die Domains werden **automatisch** durch den
|
||||||
|
Control Generator vergeben, basierend auf dem Inhalt der Quelldokumente.
|
||||||
|
|
||||||
|
### Top-10 Domains
|
||||||
|
|
||||||
|
| Domain | Anzahl | Thema | Hauptquellen |
|
||||||
|
|--------|--------|-------|-------------|
|
||||||
|
| SEC | ~700 | Allgemeine Sicherheit | CRA, NIS2, BSI, ENISA |
|
||||||
|
| COMP | ~470 | Compliance & Regulierung | DSGVO, AI Act, Richtlinien |
|
||||||
|
| DATA | ~400 | Datenschutz & Datenklassifizierung | DSGVO, BDSG, EDPB |
|
||||||
|
| AI | ~290 | KI-Regulierung & Ethik | AI Act, HLEG, OECD |
|
||||||
|
| LOG | ~230 | Logging & Monitoring | NIST, OWASP |
|
||||||
|
| AUTH | ~200 | Authentifizierung & Session | NIST SP 800-63, OWASP |
|
||||||
|
| NET | ~150 | Netzwerksicherheit | NIST, ENISA |
|
||||||
|
| CRYP | ~90 | Kryptographie & Schluessel | NIST SP 800-57 |
|
||||||
|
| ACC | ~25 | Zugriffskontrolle | OWASP ASVS |
|
||||||
|
| INC | ~25 | Incident Response | NIS2, CRA |
|
||||||
|
|
||||||
|
### Spezialisierte Domains
|
||||||
|
|
||||||
|
Neben den Top-10 gibt es ueber 90 weitere Domains fuer spezifische Themen:
|
||||||
|
|
||||||
|
- **CRA** — Cyber Resilience Act spezifisch
|
||||||
|
- **ARC** — Sichere Architektur
|
||||||
|
- **API** — API-Security
|
||||||
|
- **PKI** — Public Key Infrastructure
|
||||||
|
- **SUP** — Supply Chain Security
|
||||||
|
- **VUL** — Vulnerability Management
|
||||||
|
- **BCP** — Business Continuity
|
||||||
|
- **PHY** — Physische Sicherheit
|
||||||
|
- u.v.m.
|
||||||
|
|
||||||
|
### ID-Format
|
||||||
|
|
||||||
|
Control-IDs folgen dem Muster \`DOMAIN-NNN\` (z.B. AUTH-001, SEC-042). Dieses Format ist
|
||||||
|
**nicht von BSI oder anderen proprietaeren Standards abgeleitet**, sondern folgt einem
|
||||||
|
allgemein ueblichen Nummerierungsschema.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-sources',
|
||||||
|
title: 'Offene Referenzquellen',
|
||||||
|
content: `## Primaere offene Quellen
|
||||||
|
|
||||||
|
Alle Controls sind in mindestens einer der folgenden **frei zugaenglichen** Quellen verankert:
|
||||||
|
|
||||||
|
### OWASP (CC BY-SA 4.0 — kommerziell erlaubt)
|
||||||
|
- **ASVS** — Application Security Verification Standard v4.0.3
|
||||||
|
- **MASVS** — Mobile Application Security Verification Standard v2.1
|
||||||
|
- **Top 10** — OWASP Top 10 (2021)
|
||||||
|
- **Cheat Sheets** — OWASP Cheat Sheet Series
|
||||||
|
- **SAMM** — Software Assurance Maturity Model
|
||||||
|
|
||||||
|
### NIST (Public Domain — keine Einschraenkungen)
|
||||||
|
- **SP 800-53 Rev.5** — Security and Privacy Controls
|
||||||
|
- **SP 800-63B** — Digital Identity Guidelines (Authentication)
|
||||||
|
- **SP 800-57** — Key Management Recommendations
|
||||||
|
- **SP 800-52 Rev.2** — TLS Implementation Guidelines
|
||||||
|
- **SP 800-92** — Log Management Guide
|
||||||
|
- **SP 800-218 (SSDF)** — Secure Software Development Framework
|
||||||
|
- **SP 800-60** — Information Types to Security Categories
|
||||||
|
|
||||||
|
### ENISA (CC BY 4.0 — kommerziell erlaubt)
|
||||||
|
- Good Practices for IoT/Mobile Security
|
||||||
|
- Data Protection Engineering
|
||||||
|
- Algorithms, Key Sizes and Parameters Report
|
||||||
|
|
||||||
|
### Weitere offene Quellen
|
||||||
|
- **SLSA** (Supply-chain Levels for Software Artifacts) — Google Open Source
|
||||||
|
- **CIS Controls v8** (CC BY-NC-ND — nur fuer interne Analyse)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'restricted-sources',
|
||||||
|
title: 'Geschuetzte Quellen — Nur interne Analyse',
|
||||||
|
content: `## Quellen mit eingeschraenkter Nutzung
|
||||||
|
|
||||||
|
Die folgenden Quellen werden **ausschliesslich intern zur Analyse** verwendet.
|
||||||
|
Kein Text, keine Struktur, keine Bezeichner aus diesen Quellen erscheinen im Produkt.
|
||||||
|
|
||||||
|
### BSI (Nutzungsbedingungen — kommerziell eingeschraenkt)
|
||||||
|
- TR-03161 Teil 1-3 (Mobile, Web, Hintergrunddienste)
|
||||||
|
- Nutzung: TDM unter UrhG §44b, Kopien werden geloescht
|
||||||
|
- Kein Shipping von Zitaten, Embeddings oder Strukturen
|
||||||
|
|
||||||
|
### ISO/IEC (Kostenpflichtig — kein Shipping)
|
||||||
|
- ISO 27001, ISO 27002
|
||||||
|
- Nutzung: Nur als Referenz fuer Mapping, kein Text im Produkt
|
||||||
|
|
||||||
|
### ETSI (Restriktiv — kein kommerzieller Gebrauch)
|
||||||
|
- Nutzung: Nur als Hintergrundwissen, kein direkter Einfluss
|
||||||
|
|
||||||
|
### Trennungsprinzip
|
||||||
|
|
||||||
|
| Ebene | Geschuetzte Quelle | Offene Quelle |
|
||||||
|
|-------|--------------------|---------------|
|
||||||
|
| Analyse | ✅ Darf gelesen werden | ✅ Darf gelesen werden |
|
||||||
|
| Inspiration | ✅ Darf Ideen liefern | ✅ Darf Ideen liefern |
|
||||||
|
| Formulierung | ❌ Keine Uebernahme | ✅ Darf zitiert werden |
|
||||||
|
| Struktur | ❌ Keine Uebernahme | ✅ Darf verwendet werden |
|
||||||
|
| Produkttext | ❌ Nicht erlaubt | ✅ Erlaubt |`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'verification-methods',
|
||||||
|
title: 'Verifikationsmethoden',
|
||||||
|
content: `## Nachweis-Klassifizierung
|
||||||
|
|
||||||
|
Jedes Control wird einer von vier Verifikationsmethoden zugeordnet. Dies bestimmt,
|
||||||
|
**wie** ein Kunde den Nachweis fuer die Einhaltung erbringen kann:
|
||||||
|
|
||||||
|
| Methode | Beschreibung | Beispiele |
|
||||||
|
|---------|-------------|-----------|
|
||||||
|
| **Code Review** | Nachweis durch Quellcode-Inspektion | Input-Validierung, Encryption-Konfiguration, Auth-Logic |
|
||||||
|
| **Dokument** | Nachweis durch Richtlinien, Prozesse, Schulungen | Notfallplaene, Schulungsnachweise, Datenschutzkonzepte |
|
||||||
|
| **Tool** | Nachweis durch automatisierte Tools/Scans | SIEM-Logs, Vulnerability-Scans, Monitoring-Dashboards |
|
||||||
|
| **Hybrid** | Kombination aus mehreren Methoden | Zugriffskontrollen (Code + Policy + Tool) |
|
||||||
|
|
||||||
|
### Bedeutung fuer Kunden
|
||||||
|
|
||||||
|
- **Code Review Controls** koennen direkt im SDK-Scan geprueft werden
|
||||||
|
- **Dokument Controls** erfordern manuelle Uploads (PDFs, Links)
|
||||||
|
- **Tool Controls** koennen per API-Integration automatisch nachgewiesen werden
|
||||||
|
- **Hybrid Controls** benoetigen mehrere Nachweisarten`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'categories',
|
||||||
|
title: 'Thematische Kategorien',
|
||||||
|
content: `## 17 Sicherheitskategorien
|
||||||
|
|
||||||
|
Controls sind in thematische Kategorien gruppiert, um Kunden eine
|
||||||
|
uebersichtliche Navigation zu ermoeglichen:
|
||||||
|
|
||||||
|
| Kategorie | Beschreibung |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Verschluesselung & Kryptographie | TLS, Key Management, Algorithmen |
|
||||||
|
| Authentisierung & Zugriffskontrolle | Login, MFA, RBAC, Session-Management |
|
||||||
|
| Netzwerksicherheit | Firewall, Segmentierung, VPN, DNS |
|
||||||
|
| Datenschutz & Datensicherheit | DSGVO, Datenklassifizierung, Anonymisierung |
|
||||||
|
| Logging & Monitoring | SIEM, Audit-Logs, Alerting |
|
||||||
|
| Vorfallmanagement | Incident Response, Meldepflichten |
|
||||||
|
| Notfall & Wiederherstellung | BCM, Disaster Recovery, Backups |
|
||||||
|
| Compliance & Audit | Zertifizierungen, Audits, Berichtspflichten |
|
||||||
|
| Lieferkettenmanagement | Vendor Risk, SBOM, Third-Party |
|
||||||
|
| Physische Sicherheit | Zutritt, Gebaeudesicherheit |
|
||||||
|
| Personal & Schulung | Security Awareness, Rollenkonzepte |
|
||||||
|
| Anwendungssicherheit | SAST, DAST, Secure Coding |
|
||||||
|
| Systemhaertung & -betrieb | Patching, Konfiguration, Hardening |
|
||||||
|
| Risikomanagement | Risikoanalyse, Bewertung, Massnahmen |
|
||||||
|
| Sicherheitsorganisation | ISMS, Richtlinien, Governance |
|
||||||
|
| Hardware & Plattformsicherheit | TPM, Secure Boot, Firmware |
|
||||||
|
| Identitaetsmanagement | SSO, Federation, Directory |
|
||||||
|
|
||||||
|
### Abgrenzung zu Domains
|
||||||
|
|
||||||
|
Kategorien sind **thematisch**, Domains (AUTH, NET, etc.) sind **strukturell**.
|
||||||
|
Ein Control AUTH-005 (Domain AUTH) hat die Kategorie "authentication",
|
||||||
|
aber ein Control NET-012 (Domain NET) koennte ebenfalls die Kategorie
|
||||||
|
"authentication" haben, wenn es um Netzwerk-Authentifizierung geht.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'master-library',
|
||||||
|
title: 'Master Library Strategie',
|
||||||
|
content: `## RAG-First Ansatz
|
||||||
|
|
||||||
|
Die Canonical Control Library folgt einer **RAG-First-Strategie**:
|
||||||
|
|
||||||
|
### Schritt 1: Rule 1+2 Controls aus RAG generieren
|
||||||
|
|
||||||
|
Prioritaet haben Controls aus Quellen mit **Originaltext-Erlaubnis**:
|
||||||
|
|
||||||
|
| Welle | Quellen | Lizenzregel | Vorteil |
|
||||||
|
|-------|---------|------------|---------|
|
||||||
|
| 1 | OWASP (ASVS, MASVS, Top10) | Rule 2 (CC-BY-SA, Zitation) | Originaltext + Zitation |
|
||||||
|
| 2 | NIST (SP 800-53, CSF, SSDF) | Rule 1 (Public Domain) | Voller Text, keine Einschraenkungen |
|
||||||
|
| 3 | EU-Verordnungen (DSGVO, AI Act, NIS2, CRA) | Rule 1 (EU Law) | Gesetzestext + Erklaerung |
|
||||||
|
| 4 | Deutsche Gesetze (BDSG, TTDSG, TKG) | Rule 1 (DE Law) | Gesetzestext + Erklaerung |
|
||||||
|
|
||||||
|
### Schritt 2: Dedup gegen BSI Rule-3 Controls
|
||||||
|
|
||||||
|
Die ~880 BSI Rule-3 Controls werden **gegen** die neuen Rule 1+2 Controls abgeglichen:
|
||||||
|
|
||||||
|
- Wenn ein BSI-Control ein Duplikat eines OWASP/NIST-Controls ist → **OWASP/NIST bevorzugt**
|
||||||
|
(weil Originaltext + Zitation erlaubt)
|
||||||
|
- BSI-Duplikate werden als \`deprecated\` markiert
|
||||||
|
- Tags und Anchors werden in den behaltenen Control zusammengefuehrt
|
||||||
|
|
||||||
|
### Schritt 3: Aktueller Stand
|
||||||
|
|
||||||
|
Aktuell: **~3.100+ Controls** (Stand Maerz 2026), davon:
|
||||||
|
- Viele mit \`source_original_text\` (Originaltext fuer Kunden sichtbar)
|
||||||
|
- Viele mit \`source_citation\` (Quellenangabe mit Lizenz)
|
||||||
|
- Klare Nachweismethode (\`verification_method\`)
|
||||||
|
- Thematische Kategorie (\`category\`)
|
||||||
|
|
||||||
|
### Verstaendliche Texte
|
||||||
|
|
||||||
|
Zusaetzlich zum Originaltext (der oft juristisch/technisch formuliert ist)
|
||||||
|
enthaelt jedes Control ein eigenstaendig formuliertes **Ziel** (objective)
|
||||||
|
und eine **Begruendung** (rationale) in verstaendlicher Sprache.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'validation',
|
||||||
|
title: 'Automatisierte Validierung',
|
||||||
|
content: `## CI/CD-Pruefungen
|
||||||
|
|
||||||
|
Jedes Control wird bei jedem Commit automatisch geprueft:
|
||||||
|
|
||||||
|
### 1. Schema-Validierung
|
||||||
|
- Alle Pflichtfelder vorhanden
|
||||||
|
- Control-ID Format: \`^[A-Z]{2,6}-[0-9]{3}$\`
|
||||||
|
- Severity: low, medium, high, critical
|
||||||
|
- Risk Score: 0-10
|
||||||
|
|
||||||
|
### 2. No-Leak Scanner
|
||||||
|
Regex-Pruefung gegen verbotene Muster in produktfaehigen Feldern:
|
||||||
|
- \`O.[A-Za-z]+_[0-9]+\` — BSI Objective-IDs
|
||||||
|
- \`TR-03161\` — Direkte BSI-TR-Referenzen
|
||||||
|
- \`BSI-TR-\` — BSI-spezifische Locators
|
||||||
|
- \`Anforderung [A-Z].[0-9]+\` — BSI-Anforderungsformat
|
||||||
|
|
||||||
|
### 3. Open Anchor Check
|
||||||
|
Jedes freigegebene Control muss mindestens 1 Open-Source-Referenz haben.
|
||||||
|
|
||||||
|
### 4. Too-Close Detektor (5 Metriken)
|
||||||
|
|
||||||
|
| Metrik | Warn | Fail | Beschreibung |
|
||||||
|
|--------|------|------|-------------|
|
||||||
|
| Exact Phrase | ≥8 Tokens | ≥12 Tokens | Laengste identische Token-Sequenz |
|
||||||
|
| Token Overlap | ≥0.20 | ≥0.30 | Jaccard-Aehnlichkeit der Token-Mengen |
|
||||||
|
| 3-Gram Jaccard | ≥0.10 | ≥0.18 | Zeichenketten-Aehnlichkeit |
|
||||||
|
| Embedding Cosine | ≥0.86 | ≥0.92 | Semantische Aehnlichkeit (bge-m3) |
|
||||||
|
| LCS Ratio | ≥0.35 | ≥0.50 | Longest Common Subsequence |
|
||||||
|
|
||||||
|
**Entscheidungslogik:**
|
||||||
|
- **PASS** — Kein Fail + max 1 Warn
|
||||||
|
- **WARN** — Max 2 Warn, kein Fail → Human Review erforderlich
|
||||||
|
- **FAIL** — Irgendein Fail → Blockiert, Umformulierung noetig`,
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,452 +1,27 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import {
|
import { Shield, FileText } from 'lucide-react'
|
||||||
Shield, BookOpen, ExternalLink, CheckCircle2, AlertTriangle,
|
|
||||||
Lock, Scale, FileText, Eye, ArrowLeft,
|
|
||||||
} from 'lucide-react'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
import { PROVENANCE_SECTIONS } from './_data/provenance-sections'
|
||||||
// =============================================================================
|
import { MarkdownRenderer } from './_components/MarkdownRenderer'
|
||||||
// TYPES
|
import { LicenseMatrix } from './_components/LicenseMatrix'
|
||||||
// =============================================================================
|
import { SourceRegistry } from './_components/SourceRegistry'
|
||||||
|
|
||||||
interface LicenseInfo {
|
interface LicenseInfo {
|
||||||
license_id: string
|
license_id: string; name: string; terms_url: string | null; commercial_use: string
|
||||||
name: string
|
ai_training_restriction: string | null; tdm_allowed_under_44b: string | null
|
||||||
terms_url: string | null
|
deletion_required: boolean; notes: string | null
|
||||||
commercial_use: string
|
|
||||||
ai_training_restriction: string | null
|
|
||||||
tdm_allowed_under_44b: string | null
|
|
||||||
deletion_required: boolean
|
|
||||||
notes: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SourceInfo {
|
interface SourceInfo {
|
||||||
source_id: string
|
source_id: string; title: string; publisher: string; url: string | null
|
||||||
title: string
|
version_label: string | null; language: string; license_id: string; license_name: string
|
||||||
publisher: string
|
commercial_use: string; allowed_analysis: boolean; allowed_store_excerpt: boolean
|
||||||
url: string | null
|
allowed_ship_embeddings: boolean; allowed_ship_in_product: boolean
|
||||||
version_label: string | null
|
vault_retention_days: number; vault_access_tier: string
|
||||||
language: string
|
|
||||||
license_id: string
|
|
||||||
license_name: string
|
|
||||||
commercial_use: string
|
|
||||||
allowed_analysis: boolean
|
|
||||||
allowed_store_excerpt: boolean
|
|
||||||
allowed_ship_embeddings: boolean
|
|
||||||
allowed_ship_in_product: boolean
|
|
||||||
vault_retention_days: number
|
|
||||||
vault_access_tier: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// STATIC PROVENANCE DOCUMENTATION
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
const PROVENANCE_SECTIONS = [
|
|
||||||
{
|
|
||||||
id: 'methodology',
|
|
||||||
title: 'Methodik der Control-Erstellung',
|
|
||||||
content: `## Unabhaengige Formulierung
|
|
||||||
|
|
||||||
Alle Controls in der Canonical Control Library wurden **eigenstaendig formuliert** und folgen einer
|
|
||||||
**unabhaengigen Taxonomie**. Es werden keine proprietaeren Bezeichner, Nummern oder Strukturen
|
|
||||||
aus geschuetzten Quellen uebernommen.
|
|
||||||
|
|
||||||
### Dreistufiger Prozess
|
|
||||||
|
|
||||||
1. **Offene Recherche** — Identifikation von Security-Anforderungen aus oeffentlichen, frei zugaenglichen
|
|
||||||
Frameworks (OWASP, NIST, ENISA). Jede Anforderung wird aus mindestens 2 unabhaengigen offenen Quellen belegt.
|
|
||||||
|
|
||||||
2. **Eigenstaendige Formulierung** — Jedes Control wird mit eigener Sprache, eigener Struktur und eigener
|
|
||||||
Taxonomie (z.B. AUTH-001, NET-001) verfasst. Kein Copy-Paste, keine Paraphrase geschuetzter Texte.
|
|
||||||
|
|
||||||
3. **Too-Close-Pruefung** — Automatisierte Aehnlichkeitspruefung gegen Quelltexte mit 5 Metriken
|
|
||||||
(Token Overlap, N-Gram Jaccard, Embedding Cosine, LCS Ratio, Exact-Phrase). Nur Controls mit
|
|
||||||
Status PASS oder WARN (+ Human Review) werden freigegeben.
|
|
||||||
|
|
||||||
### Rechtliche Grundlage
|
|
||||||
|
|
||||||
- **UrhG §44b** — Text & Data Mining erlaubt fuer Analyse; Kopien werden danach geloescht
|
|
||||||
- **UrhG §23** — Hinreichender Abstand zum Originalwerk durch eigene Formulierung
|
|
||||||
- **BSI Nutzungsbedingungen** — Kommerzielle Nutzung nur mit Zustimmung; wir nutzen BSI-Dokumente
|
|
||||||
ausschliesslich als Analysegrundlage, nicht im Produkt`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'filters',
|
|
||||||
title: 'Filter in der Control Library',
|
|
||||||
content: `## Dropdown-Filter
|
|
||||||
|
|
||||||
Die Control Library bietet 7 Filter-Dropdowns, um die ueber 3.000 Controls effizient zu durchsuchen:
|
|
||||||
|
|
||||||
### Schweregrad (Severity)
|
|
||||||
|
|
||||||
| Stufe | Farbe | Bedeutung |
|
|
||||||
|-------|-------|-----------|
|
|
||||||
| **Kritisch** | Rot | Sicherheitskritische Controls — Verstoesse fuehren zu schwerwiegenden Risiken |
|
|
||||||
| **Hoch** | Orange | Wichtige Controls — sollten zeitnah umgesetzt werden |
|
|
||||||
| **Mittel** | Gelb | Standardmaessige Controls — empfohlene Umsetzung |
|
|
||||||
| **Niedrig** | Gruen | Nice-to-have Controls — zusaetzliche Haertung |
|
|
||||||
|
|
||||||
### Domain
|
|
||||||
|
|
||||||
Das Praefix der Control-ID (z.B. \`AUTH-001\`, \`SEC-042\`). Kennzeichnet den thematischen Bereich.
|
|
||||||
Die haeufigsten Domains:
|
|
||||||
|
|
||||||
| Domain | Anzahl | Thema |
|
|
||||||
|--------|--------|-------|
|
|
||||||
| SEC | ~700 | Allgemeine Sicherheit, Systemhaertung |
|
|
||||||
| COMP | ~470 | Compliance, Regulierung, Nachweispflichten |
|
|
||||||
| DATA | ~400 | Datenschutz, Datenklassifizierung, DSGVO |
|
|
||||||
| AI | ~290 | KI-Regulierung (AI Act, Transparenz, Erklaerbarkeit) |
|
|
||||||
| LOG | ~230 | Logging, Monitoring, SIEM |
|
|
||||||
| AUTH | ~200 | Authentifizierung, Zugriffskontrolle |
|
|
||||||
| NET | ~150 | Netzwerksicherheit, Transport, Firewall |
|
|
||||||
| CRYP | ~90 | Kryptographie, Schluesselmanagement |
|
|
||||||
| ACC | ~25 | Zugriffskontrolle (Access Control) |
|
|
||||||
| INC | ~25 | Incident Response, Vorfallmanagement |
|
|
||||||
|
|
||||||
Zusaetzlich existieren spezialisierte Domains wie CRA, ARC (Architektur), API, PKI, SUP (Supply Chain) u.v.m.
|
|
||||||
|
|
||||||
### Status (Release State)
|
|
||||||
|
|
||||||
| Status | Bedeutung |
|
|
||||||
|--------|-----------|
|
|
||||||
| **Draft** | Entwurf — noch nicht freigegeben |
|
|
||||||
| **Approved** | Freigegeben fuer Kunden |
|
|
||||||
| **Review noetig** | Muss manuell geprueft werden |
|
|
||||||
| **Zu aehnlich** | Too-Close-Check hat Warnung ausgeloest |
|
|
||||||
| **Duplikat** | Wurde als Duplikat eines anderen Controls erkannt |
|
|
||||||
|
|
||||||
### Nachweis (Verification Method)
|
|
||||||
|
|
||||||
| Methode | Farbe | Beschreibung |
|
|
||||||
|---------|-------|-------------|
|
|
||||||
| **Code Review** | Blau | Nachweis durch Quellcode-Inspektion |
|
|
||||||
| **Dokument** | Amber | Nachweis durch Richtlinien, Prozesse, Schulungen |
|
|
||||||
| **Tool** | Teal | Nachweis durch automatisierte Scans/Monitoring |
|
|
||||||
| **Hybrid** | Lila | Kombination aus mehreren Methoden |
|
|
||||||
|
|
||||||
### Kategorie
|
|
||||||
|
|
||||||
Thematische Einordnung (17 Kategorien). Kategorien sind **thematisch**, Domains **strukturell**.
|
|
||||||
Ein AUTH-Control kann z.B. die Kategorie "Netzwerksicherheit" haben.
|
|
||||||
|
|
||||||
### Zielgruppe (Target Audience)
|
|
||||||
|
|
||||||
| Zielgruppe | Bedeutung |
|
|
||||||
|------------|-----------|
|
|
||||||
| **Unternehmen** | Fuer Endkunden/Firmen relevant |
|
|
||||||
| **Behoerden** | Spezifisch fuer oeffentliche Verwaltung |
|
|
||||||
| **Anbieter** | Fuer SaaS/Plattform-Anbieter |
|
|
||||||
| **Alle** | Allgemein anwendbar |
|
|
||||||
|
|
||||||
### Dokumentenursprung (Source)
|
|
||||||
|
|
||||||
Filtert nach der Quelldokument-Herkunft des Controls. Zeigt alle Quellen sortiert nach
|
|
||||||
Haeufigkeit. Die wichtigsten Quellen:
|
|
||||||
|
|
||||||
| Quelle | Typ |
|
|
||||||
|--------|-----|
|
|
||||||
| KI-Verordnung (EU) 2024/1689 | EU-Recht |
|
|
||||||
| Cyber Resilience Act (EU) 2024/2847 | EU-Recht |
|
|
||||||
| DSGVO (EU) 2016/679 | EU-Recht |
|
|
||||||
| NIS2-Richtlinie (EU) 2022/2555 | EU-Recht |
|
|
||||||
| NIST SP 800-53, CSF 2.0, SSDF | US-Standards |
|
|
||||||
| OWASP Top 10, ASVS, SAMM | Open Source |
|
|
||||||
| ENISA Guidelines | EU-Agentur |
|
|
||||||
| CISA Secure by Design | US-Behoerde |
|
|
||||||
| BDSG, TKG, GewO, HGB | Deutsche Gesetze |
|
|
||||||
| EDPB Leitlinien | EU Datenschutz |`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'badges',
|
|
||||||
title: 'Badges & Lizenzregeln',
|
|
||||||
content: `## Badges in der Control Library
|
|
||||||
|
|
||||||
Jedes Control zeigt mehrere farbige Badges:
|
|
||||||
|
|
||||||
### Lizenzregel-Badge (Rule 1 / 2 / 3)
|
|
||||||
|
|
||||||
Die Lizenzregel bestimmt, wie ein Control erstellt und genutzt werden darf:
|
|
||||||
|
|
||||||
| Badge | Farbe | Regel | Bedeutung |
|
|
||||||
|-------|-------|-------|-----------|
|
|
||||||
| **Free Use** | Gruen | Rule 1 | Quelle ist Public Domain oder EU-Recht — Originaltext darf gezeigt werden |
|
|
||||||
| **Zitation** | Blau | Rule 2 | Quelle ist CC-BY oder aehnlich — Zitation + Quellenangabe erforderlich |
|
|
||||||
| **Reformuliert** | Amber | Rule 3 | Quelle hat eingeschraenkte Lizenz — Control wurde eigenstaendig reformuliert, kein Originaltext |
|
|
||||||
|
|
||||||
### Processing-Path
|
|
||||||
|
|
||||||
| Pfad | Bedeutung |
|
|
||||||
|------|-----------|
|
|
||||||
| **structured** | Control wurde direkt aus strukturierten Daten (Tabellen, Listen) extrahiert |
|
|
||||||
| **llm_reform** | Control wurde mit LLM eigenstaendig formuliert (bei Rule 3 zwingend) |
|
|
||||||
|
|
||||||
### Referenzen (Open Anchors)
|
|
||||||
|
|
||||||
Zeigt die Anzahl der verlinkten Open-Source-Referenzen (OWASP, NIST, ENISA etc.).
|
|
||||||
Jedes freigegebene Control muss mindestens 1 Open Anchor haben.
|
|
||||||
|
|
||||||
### Weitere Badges
|
|
||||||
|
|
||||||
| Badge | Bedeutung |
|
|
||||||
|-------|-----------|
|
|
||||||
| Score | Risiko-Score (0-10) |
|
|
||||||
| Severity-Badge | Schweregrad (Kritisch/Hoch/Mittel/Niedrig) |
|
|
||||||
| State-Badge | Freigabestatus (Draft/Approved/etc.) |
|
|
||||||
| Kategorie-Badge | Thematische Kategorie |
|
|
||||||
| Zielgruppe-Badge | Enterprise/Behoerden/Anbieter/Alle |`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taxonomy',
|
|
||||||
title: 'Unabhaengige Taxonomie',
|
|
||||||
content: `## Eigenes Klassifikationssystem
|
|
||||||
|
|
||||||
Die Canonical Control Library verwendet ein **eigenes Domain-Schema**, das sich bewusst von
|
|
||||||
proprietaeren Frameworks unterscheidet. Die Domains werden **automatisch** durch den
|
|
||||||
Control Generator vergeben, basierend auf dem Inhalt der Quelldokumente.
|
|
||||||
|
|
||||||
### Top-10 Domains
|
|
||||||
|
|
||||||
| Domain | Anzahl | Thema | Hauptquellen |
|
|
||||||
|--------|--------|-------|-------------|
|
|
||||||
| SEC | ~700 | Allgemeine Sicherheit | CRA, NIS2, BSI, ENISA |
|
|
||||||
| COMP | ~470 | Compliance & Regulierung | DSGVO, AI Act, Richtlinien |
|
|
||||||
| DATA | ~400 | Datenschutz & Datenklassifizierung | DSGVO, BDSG, EDPB |
|
|
||||||
| AI | ~290 | KI-Regulierung & Ethik | AI Act, HLEG, OECD |
|
|
||||||
| LOG | ~230 | Logging & Monitoring | NIST, OWASP |
|
|
||||||
| AUTH | ~200 | Authentifizierung & Session | NIST SP 800-63, OWASP |
|
|
||||||
| NET | ~150 | Netzwerksicherheit | NIST, ENISA |
|
|
||||||
| CRYP | ~90 | Kryptographie & Schluessel | NIST SP 800-57 |
|
|
||||||
| ACC | ~25 | Zugriffskontrolle | OWASP ASVS |
|
|
||||||
| INC | ~25 | Incident Response | NIS2, CRA |
|
|
||||||
|
|
||||||
### Spezialisierte Domains
|
|
||||||
|
|
||||||
Neben den Top-10 gibt es ueber 90 weitere Domains fuer spezifische Themen:
|
|
||||||
|
|
||||||
- **CRA** — Cyber Resilience Act spezifisch
|
|
||||||
- **ARC** — Sichere Architektur
|
|
||||||
- **API** — API-Security
|
|
||||||
- **PKI** — Public Key Infrastructure
|
|
||||||
- **SUP** — Supply Chain Security
|
|
||||||
- **VUL** — Vulnerability Management
|
|
||||||
- **BCP** — Business Continuity
|
|
||||||
- **PHY** — Physische Sicherheit
|
|
||||||
- u.v.m.
|
|
||||||
|
|
||||||
### ID-Format
|
|
||||||
|
|
||||||
Control-IDs folgen dem Muster \`DOMAIN-NNN\` (z.B. AUTH-001, SEC-042). Dieses Format ist
|
|
||||||
**nicht von BSI oder anderen proprietaeren Standards abgeleitet**, sondern folgt einem
|
|
||||||
allgemein ueblichen Nummerierungsschema.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'open-sources',
|
|
||||||
title: 'Offene Referenzquellen',
|
|
||||||
content: `## Primaere offene Quellen
|
|
||||||
|
|
||||||
Alle Controls sind in mindestens einer der folgenden **frei zugaenglichen** Quellen verankert:
|
|
||||||
|
|
||||||
### OWASP (CC BY-SA 4.0 — kommerziell erlaubt)
|
|
||||||
- **ASVS** — Application Security Verification Standard v4.0.3
|
|
||||||
- **MASVS** — Mobile Application Security Verification Standard v2.1
|
|
||||||
- **Top 10** — OWASP Top 10 (2021)
|
|
||||||
- **Cheat Sheets** — OWASP Cheat Sheet Series
|
|
||||||
- **SAMM** — Software Assurance Maturity Model
|
|
||||||
|
|
||||||
### NIST (Public Domain — keine Einschraenkungen)
|
|
||||||
- **SP 800-53 Rev.5** — Security and Privacy Controls
|
|
||||||
- **SP 800-63B** — Digital Identity Guidelines (Authentication)
|
|
||||||
- **SP 800-57** — Key Management Recommendations
|
|
||||||
- **SP 800-52 Rev.2** — TLS Implementation Guidelines
|
|
||||||
- **SP 800-92** — Log Management Guide
|
|
||||||
- **SP 800-218 (SSDF)** — Secure Software Development Framework
|
|
||||||
- **SP 800-60** — Information Types to Security Categories
|
|
||||||
|
|
||||||
### ENISA (CC BY 4.0 — kommerziell erlaubt)
|
|
||||||
- Good Practices for IoT/Mobile Security
|
|
||||||
- Data Protection Engineering
|
|
||||||
- Algorithms, Key Sizes and Parameters Report
|
|
||||||
|
|
||||||
### Weitere offene Quellen
|
|
||||||
- **SLSA** (Supply-chain Levels for Software Artifacts) — Google Open Source
|
|
||||||
- **CIS Controls v8** (CC BY-NC-ND — nur fuer interne Analyse)`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'restricted-sources',
|
|
||||||
title: 'Geschuetzte Quellen — Nur interne Analyse',
|
|
||||||
content: `## Quellen mit eingeschraenkter Nutzung
|
|
||||||
|
|
||||||
Die folgenden Quellen werden **ausschliesslich intern zur Analyse** verwendet.
|
|
||||||
Kein Text, keine Struktur, keine Bezeichner aus diesen Quellen erscheinen im Produkt.
|
|
||||||
|
|
||||||
### BSI (Nutzungsbedingungen — kommerziell eingeschraenkt)
|
|
||||||
- TR-03161 Teil 1-3 (Mobile, Web, Hintergrunddienste)
|
|
||||||
- Nutzung: TDM unter UrhG §44b, Kopien werden geloescht
|
|
||||||
- Kein Shipping von Zitaten, Embeddings oder Strukturen
|
|
||||||
|
|
||||||
### ISO/IEC (Kostenpflichtig — kein Shipping)
|
|
||||||
- ISO 27001, ISO 27002
|
|
||||||
- Nutzung: Nur als Referenz fuer Mapping, kein Text im Produkt
|
|
||||||
|
|
||||||
### ETSI (Restriktiv — kein kommerzieller Gebrauch)
|
|
||||||
- Nutzung: Nur als Hintergrundwissen, kein direkter Einfluss
|
|
||||||
|
|
||||||
### Trennungsprinzip
|
|
||||||
|
|
||||||
| Ebene | Geschuetzte Quelle | Offene Quelle |
|
|
||||||
|-------|--------------------|---------------|
|
|
||||||
| Analyse | ✅ Darf gelesen werden | ✅ Darf gelesen werden |
|
|
||||||
| Inspiration | ✅ Darf Ideen liefern | ✅ Darf Ideen liefern |
|
|
||||||
| Formulierung | ❌ Keine Uebernahme | ✅ Darf zitiert werden |
|
|
||||||
| Struktur | ❌ Keine Uebernahme | ✅ Darf verwendet werden |
|
|
||||||
| Produkttext | ❌ Nicht erlaubt | ✅ Erlaubt |`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'verification-methods',
|
|
||||||
title: 'Verifikationsmethoden',
|
|
||||||
content: `## Nachweis-Klassifizierung
|
|
||||||
|
|
||||||
Jedes Control wird einer von vier Verifikationsmethoden zugeordnet. Dies bestimmt,
|
|
||||||
**wie** ein Kunde den Nachweis fuer die Einhaltung erbringen kann:
|
|
||||||
|
|
||||||
| Methode | Beschreibung | Beispiele |
|
|
||||||
|---------|-------------|-----------|
|
|
||||||
| **Code Review** | Nachweis durch Quellcode-Inspektion | Input-Validierung, Encryption-Konfiguration, Auth-Logic |
|
|
||||||
| **Dokument** | Nachweis durch Richtlinien, Prozesse, Schulungen | Notfallplaene, Schulungsnachweise, Datenschutzkonzepte |
|
|
||||||
| **Tool** | Nachweis durch automatisierte Tools/Scans | SIEM-Logs, Vulnerability-Scans, Monitoring-Dashboards |
|
|
||||||
| **Hybrid** | Kombination aus mehreren Methoden | Zugriffskontrollen (Code + Policy + Tool) |
|
|
||||||
|
|
||||||
### Bedeutung fuer Kunden
|
|
||||||
|
|
||||||
- **Code Review Controls** koennen direkt im SDK-Scan geprueft werden
|
|
||||||
- **Dokument Controls** erfordern manuelle Uploads (PDFs, Links)
|
|
||||||
- **Tool Controls** koennen per API-Integration automatisch nachgewiesen werden
|
|
||||||
- **Hybrid Controls** benoetigen mehrere Nachweisarten`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'categories',
|
|
||||||
title: 'Thematische Kategorien',
|
|
||||||
content: `## 17 Sicherheitskategorien
|
|
||||||
|
|
||||||
Controls sind in thematische Kategorien gruppiert, um Kunden eine
|
|
||||||
uebersichtliche Navigation zu ermoeglichen:
|
|
||||||
|
|
||||||
| Kategorie | Beschreibung |
|
|
||||||
|-----------|-------------|
|
|
||||||
| Verschluesselung & Kryptographie | TLS, Key Management, Algorithmen |
|
|
||||||
| Authentisierung & Zugriffskontrolle | Login, MFA, RBAC, Session-Management |
|
|
||||||
| Netzwerksicherheit | Firewall, Segmentierung, VPN, DNS |
|
|
||||||
| Datenschutz & Datensicherheit | DSGVO, Datenklassifizierung, Anonymisierung |
|
|
||||||
| Logging & Monitoring | SIEM, Audit-Logs, Alerting |
|
|
||||||
| Vorfallmanagement | Incident Response, Meldepflichten |
|
|
||||||
| Notfall & Wiederherstellung | BCM, Disaster Recovery, Backups |
|
|
||||||
| Compliance & Audit | Zertifizierungen, Audits, Berichtspflichten |
|
|
||||||
| Lieferkettenmanagement | Vendor Risk, SBOM, Third-Party |
|
|
||||||
| Physische Sicherheit | Zutritt, Gebaeudesicherheit |
|
|
||||||
| Personal & Schulung | Security Awareness, Rollenkonzepte |
|
|
||||||
| Anwendungssicherheit | SAST, DAST, Secure Coding |
|
|
||||||
| Systemhaertung & -betrieb | Patching, Konfiguration, Hardening |
|
|
||||||
| Risikomanagement | Risikoanalyse, Bewertung, Massnahmen |
|
|
||||||
| Sicherheitsorganisation | ISMS, Richtlinien, Governance |
|
|
||||||
| Hardware & Plattformsicherheit | TPM, Secure Boot, Firmware |
|
|
||||||
| Identitaetsmanagement | SSO, Federation, Directory |
|
|
||||||
|
|
||||||
### Abgrenzung zu Domains
|
|
||||||
|
|
||||||
Kategorien sind **thematisch**, Domains (AUTH, NET, etc.) sind **strukturell**.
|
|
||||||
Ein Control AUTH-005 (Domain AUTH) hat die Kategorie "authentication",
|
|
||||||
aber ein Control NET-012 (Domain NET) koennte ebenfalls die Kategorie
|
|
||||||
"authentication" haben, wenn es um Netzwerk-Authentifizierung geht.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'master-library',
|
|
||||||
title: 'Master Library Strategie',
|
|
||||||
content: `## RAG-First Ansatz
|
|
||||||
|
|
||||||
Die Canonical Control Library folgt einer **RAG-First-Strategie**:
|
|
||||||
|
|
||||||
### Schritt 1: Rule 1+2 Controls aus RAG generieren
|
|
||||||
|
|
||||||
Prioritaet haben Controls aus Quellen mit **Originaltext-Erlaubnis**:
|
|
||||||
|
|
||||||
| Welle | Quellen | Lizenzregel | Vorteil |
|
|
||||||
|-------|---------|------------|---------|
|
|
||||||
| 1 | OWASP (ASVS, MASVS, Top10) | Rule 2 (CC-BY-SA, Zitation) | Originaltext + Zitation |
|
|
||||||
| 2 | NIST (SP 800-53, CSF, SSDF) | Rule 1 (Public Domain) | Voller Text, keine Einschraenkungen |
|
|
||||||
| 3 | EU-Verordnungen (DSGVO, AI Act, NIS2, CRA) | Rule 1 (EU Law) | Gesetzestext + Erklaerung |
|
|
||||||
| 4 | Deutsche Gesetze (BDSG, TTDSG, TKG) | Rule 1 (DE Law) | Gesetzestext + Erklaerung |
|
|
||||||
|
|
||||||
### Schritt 2: Dedup gegen BSI Rule-3 Controls
|
|
||||||
|
|
||||||
Die ~880 BSI Rule-3 Controls werden **gegen** die neuen Rule 1+2 Controls abgeglichen:
|
|
||||||
|
|
||||||
- Wenn ein BSI-Control ein Duplikat eines OWASP/NIST-Controls ist → **OWASP/NIST bevorzugt**
|
|
||||||
(weil Originaltext + Zitation erlaubt)
|
|
||||||
- BSI-Duplikate werden als \`deprecated\` markiert
|
|
||||||
- Tags und Anchors werden in den behaltenen Control zusammengefuehrt
|
|
||||||
|
|
||||||
### Schritt 3: Aktueller Stand
|
|
||||||
|
|
||||||
Aktuell: **~3.100+ Controls** (Stand Maerz 2026), davon:
|
|
||||||
- Viele mit \`source_original_text\` (Originaltext fuer Kunden sichtbar)
|
|
||||||
- Viele mit \`source_citation\` (Quellenangabe mit Lizenz)
|
|
||||||
- Klare Nachweismethode (\`verification_method\`)
|
|
||||||
- Thematische Kategorie (\`category\`)
|
|
||||||
|
|
||||||
### Verstaendliche Texte
|
|
||||||
|
|
||||||
Zusaetzlich zum Originaltext (der oft juristisch/technisch formuliert ist)
|
|
||||||
enthaelt jedes Control ein eigenstaendig formuliertes **Ziel** (objective)
|
|
||||||
und eine **Begruendung** (rationale) in verstaendlicher Sprache.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'validation',
|
|
||||||
title: 'Automatisierte Validierung',
|
|
||||||
content: `## CI/CD-Pruefungen
|
|
||||||
|
|
||||||
Jedes Control wird bei jedem Commit automatisch geprueft:
|
|
||||||
|
|
||||||
### 1. Schema-Validierung
|
|
||||||
- Alle Pflichtfelder vorhanden
|
|
||||||
- Control-ID Format: \`^[A-Z]{2,6}-[0-9]{3}$\`
|
|
||||||
- Severity: low, medium, high, critical
|
|
||||||
- Risk Score: 0-10
|
|
||||||
|
|
||||||
### 2. No-Leak Scanner
|
|
||||||
Regex-Pruefung gegen verbotene Muster in produktfaehigen Feldern:
|
|
||||||
- \`O.[A-Za-z]+_[0-9]+\` — BSI Objective-IDs
|
|
||||||
- \`TR-03161\` — Direkte BSI-TR-Referenzen
|
|
||||||
- \`BSI-TR-\` — BSI-spezifische Locators
|
|
||||||
- \`Anforderung [A-Z].[0-9]+\` — BSI-Anforderungsformat
|
|
||||||
|
|
||||||
### 3. Open Anchor Check
|
|
||||||
Jedes freigegebene Control muss mindestens 1 Open-Source-Referenz haben.
|
|
||||||
|
|
||||||
### 4. Too-Close Detektor (5 Metriken)
|
|
||||||
|
|
||||||
| Metrik | Warn | Fail | Beschreibung |
|
|
||||||
|--------|------|------|-------------|
|
|
||||||
| Exact Phrase | ≥8 Tokens | ≥12 Tokens | Laengste identische Token-Sequenz |
|
|
||||||
| Token Overlap | ≥0.20 | ≥0.30 | Jaccard-Aehnlichkeit der Token-Mengen |
|
|
||||||
| 3-Gram Jaccard | ≥0.10 | ≥0.18 | Zeichenketten-Aehnlichkeit |
|
|
||||||
| Embedding Cosine | ≥0.86 | ≥0.92 | Semantische Aehnlichkeit (bge-m3) |
|
|
||||||
| LCS Ratio | ≥0.35 | ≥0.50 | Longest Common Subsequence |
|
|
||||||
|
|
||||||
**Entscheidungslogik:**
|
|
||||||
- **PASS** — Kein Fail + max 1 Warn
|
|
||||||
- **WARN** — Max 2 Warn, kein Fail → Human Review erforderlich
|
|
||||||
- **FAIL** — Irgendein Fail → Blockiert, Umformulierung noetig`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// PAGE
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
export default function ControlProvenancePage() {
|
export default function ControlProvenancePage() {
|
||||||
const [licenses, setLicenses] = useState<LicenseInfo[]>([])
|
const [licenses, setLicenses] = useState<LicenseInfo[]>([])
|
||||||
const [sources, setSources] = useState<SourceInfo[]>([])
|
const [sources, setSources] = useState<SourceInfo[]>([])
|
||||||
@@ -475,7 +50,6 @@ export default function ControlProvenancePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
|
||||||
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
<div className="border-b border-gray-200 bg-white px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="w-6 h-6 text-green-600" />
|
<FileText className="w-6 h-6 text-green-600" />
|
||||||
@@ -485,10 +59,7 @@ export default function ControlProvenancePage() {
|
|||||||
Dokumentation der unabhaengigen Herkunft aller Security Controls — rechtssicherer Nachweis
|
Dokumentation der unabhaengigen Herkunft aller Security Controls — rechtssicherer Nachweis
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link href="/sdk/control-library" className="ml-auto flex items-center gap-1 text-sm text-purple-600 hover:text-purple-800">
|
||||||
href="/sdk/control-library"
|
|
||||||
className="ml-auto flex items-center gap-1 text-sm text-purple-600 hover:text-purple-800"
|
|
||||||
>
|
|
||||||
<Shield className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
Zur Control Library
|
Zur Control Library
|
||||||
</Link>
|
</Link>
|
||||||
@@ -513,29 +84,19 @@ export default function ControlProvenancePage() {
|
|||||||
{section.title}
|
{section.title}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="border-t border-gray-200 mt-3 pt-3">
|
<div className="border-t border-gray-200 mt-3 pt-3">
|
||||||
<p className="text-xs font-semibold text-gray-400 uppercase px-3 mb-2">Live-Daten</p>
|
<p className="text-xs font-semibold text-gray-400 uppercase px-3 mb-2">Live-Daten</p>
|
||||||
|
{['license-matrix', 'source-registry'].map(id => (
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveSection('license-matrix')}
|
key={id}
|
||||||
|
onClick={() => setActiveSection(id)}
|
||||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||||
activeSection === 'license-matrix'
|
activeSection === id ? 'bg-green-100 text-green-900 font-medium' : 'text-gray-700 hover:bg-gray-100'
|
||||||
? 'bg-green-100 text-green-900 font-medium'
|
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Lizenz-Matrix
|
{id === 'license-matrix' ? 'Lizenz-Matrix' : 'Quellenregister'}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveSection('source-registry')}
|
|
||||||
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
|
|
||||||
activeSection === 'source-registry'
|
|
||||||
? 'bg-green-100 text-green-900 font-medium'
|
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Quellenregister
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -543,7 +104,6 @@ export default function ControlProvenancePage() {
|
|||||||
{/* Right: Content */}
|
{/* Right: Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
{/* Static documentation sections */}
|
|
||||||
{currentSection && (
|
{currentSection && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">{currentSection.title}</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{currentSection.title}</h2>
|
||||||
@@ -552,188 +112,11 @@ export default function ControlProvenancePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{activeSection === 'license-matrix' && <LicenseMatrix licenses={licenses} loading={loading} />}
|
||||||
{/* License Matrix (live data) */}
|
{activeSection === 'source-registry' && <SourceRegistry sources={sources} loading={loading} />}
|
||||||
{activeSection === 'license-matrix' && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Lizenz-Matrix</h2>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Uebersicht aller Lizenzen mit ihren erlaubten Nutzungsarten.
|
|
||||||
</p>
|
|
||||||
{loading ? (
|
|
||||||
<div className="animate-pulse h-32 bg-gray-100 rounded" />
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-50">
|
|
||||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Lizenz</th>
|
|
||||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Kommerziell</th>
|
|
||||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">AI-Training</th>
|
|
||||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">TDM (§44b)</th>
|
|
||||||
<th className="text-left px-3 py-2 border-b font-medium text-gray-600">Loeschpflicht</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{licenses.map(lic => (
|
|
||||||
<tr key={lic.license_id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-3 py-2 border-b">
|
|
||||||
<div className="font-medium text-gray-900">{lic.license_id}</div>
|
|
||||||
<div className="text-xs text-gray-500">{lic.name}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 border-b">
|
|
||||||
<UsageBadge value={lic.commercial_use} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 border-b">
|
|
||||||
<UsageBadge value={lic.ai_training_restriction || 'n/a'} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 border-b">
|
|
||||||
<UsageBadge value={lic.tdm_allowed_under_44b || 'unclear'} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 border-b">
|
|
||||||
{lic.deletion_required ? (
|
|
||||||
<span className="text-red-600 text-xs font-medium">Ja</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-green-600 text-xs font-medium">Nein</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Source Registry (live data) */}
|
|
||||||
{activeSection === 'source-registry' && (
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Quellenregister</h2>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Alle registrierten Quellen mit ihren Berechtigungen.
|
|
||||||
</p>
|
|
||||||
{loading ? (
|
|
||||||
<div className="animate-pulse h-32 bg-gray-100 rounded" />
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sources.map(src => (
|
|
||||||
<div key={src.source_id} className="bg-white border border-gray-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-start justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-900">{src.title}</h3>
|
|
||||||
<p className="text-xs text-gray-500">{src.publisher} — {src.license_name}</p>
|
|
||||||
</div>
|
|
||||||
{src.url && (
|
|
||||||
<a
|
|
||||||
href={src.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-3 h-3" />
|
|
||||||
Quelle
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 mt-2">
|
|
||||||
<PermBadge label="Analyse" allowed={src.allowed_analysis} />
|
|
||||||
<PermBadge label="Excerpt" allowed={src.allowed_store_excerpt} />
|
|
||||||
<PermBadge label="Embeddings" allowed={src.allowed_ship_embeddings} />
|
|
||||||
<PermBadge label="Produkt" allowed={src.allowed_ship_in_product} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// HELPER COMPONENTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function UsageBadge({ value }: { value: string }) {
|
|
||||||
const config: Record<string, { bg: string; label: string }> = {
|
|
||||||
allowed: { bg: 'bg-green-100 text-green-800', label: 'Erlaubt' },
|
|
||||||
restricted: { bg: 'bg-yellow-100 text-yellow-800', label: 'Eingeschraenkt' },
|
|
||||||
prohibited: { bg: 'bg-red-100 text-red-800', label: 'Verboten' },
|
|
||||||
unclear: { bg: 'bg-gray-100 text-gray-600', label: 'Unklar' },
|
|
||||||
yes: { bg: 'bg-green-100 text-green-800', label: 'Ja' },
|
|
||||||
no: { bg: 'bg-red-100 text-red-800', label: 'Nein' },
|
|
||||||
'n/a': { bg: 'bg-gray-100 text-gray-400', label: 'k.A.' },
|
|
||||||
}
|
|
||||||
const c = config[value] || config.unclear
|
|
||||||
return <span className={`inline-flex px-1.5 py-0.5 rounded text-xs font-medium ${c.bg}`}>{c.label}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
function PermBadge({ label, allowed }: { label: string; allowed: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{allowed ? (
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<Lock className="w-3 h-3 text-red-400" />
|
|
||||||
)}
|
|
||||||
<span className={`text-xs ${allowed ? 'text-green-700' : 'text-red-500'}`}>{label}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MarkdownRenderer({ content }: { content: string }) {
|
|
||||||
let html = content
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
|
|
||||||
// Code blocks
|
|
||||||
html = html.replace(
|
|
||||||
/^```[\w]*\n([\s\S]*?)^```$/gm,
|
|
||||||
(_m, code: string) => `<pre class="bg-gray-50 border rounded p-3 my-3 text-xs font-mono overflow-x-auto whitespace-pre">${code.trimEnd()}</pre>`
|
|
||||||
)
|
|
||||||
|
|
||||||
// Tables
|
|
||||||
html = html.replace(
|
|
||||||
/^(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)*)/gm,
|
|
||||||
(_m, header: string, _sep: string, body: string) => {
|
|
||||||
const ths = header.split('|').filter((c: string) => c.trim()).map((c: string) =>
|
|
||||||
`<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase border-b">${c.trim()}</th>`
|
|
||||||
).join('')
|
|
||||||
const rows = body.trim().split('\n').map((row: string) => {
|
|
||||||
const tds = row.split('|').filter((c: string) => c.trim()).map((c: string) =>
|
|
||||||
`<td class="px-3 py-2 text-sm text-gray-700 border-b border-gray-100">${c.trim()}</td>`
|
|
||||||
).join('')
|
|
||||||
return `<tr>${tds}</tr>`
|
|
||||||
}).join('')
|
|
||||||
return `<table class="w-full border-collapse my-3 text-sm"><thead><tr>${ths}</tr></thead><tbody>${rows}</tbody></table>`
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Headers
|
|
||||||
html = html.replace(/^### (.+)$/gm, '<h4 class="text-sm font-semibold text-gray-800 mt-4 mb-2">$1</h4>')
|
|
||||||
html = html.replace(/^## (.+)$/gm, '<h3 class="text-base font-semibold text-gray-900 mt-5 mb-2">$1</h3>')
|
|
||||||
|
|
||||||
// Bold
|
|
||||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
|
|
||||||
// Inline code
|
|
||||||
html = html.replace(/`([^`]+)`/g, '<code class="bg-gray-100 px-1 py-0.5 rounded text-xs font-mono">$1</code>')
|
|
||||||
|
|
||||||
// Lists
|
|
||||||
html = html.replace(/^- (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-disc">$1</li>')
|
|
||||||
html = html.replace(/((?:<li[^>]*>.*<\/li>\n?)+)/g, '<ul class="my-2 space-y-1">$1</ul>')
|
|
||||||
|
|
||||||
// Numbered lists
|
|
||||||
html = html.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 text-sm text-gray-700 list-decimal">$2</li>')
|
|
||||||
|
|
||||||
// Paragraphs
|
|
||||||
html = html.replace(/^(?!<[hultdp]|$)(.+)$/gm, '<p class="text-sm text-gray-700 my-2">$1</p>')
|
|
||||||
|
|
||||||
return <div dangerouslySetInnerHTML={{ __html: html }} />
|
|
||||||
}
|
|
||||||
|
|||||||
104
admin-compliance/app/sdk/controls/_components/AddControlForm.tsx
Normal file
104
admin-compliance/app/sdk/controls/_components/AddControlForm.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { ControlType } from '@/lib/sdk'
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
type: ControlType
|
||||||
|
category: string
|
||||||
|
owner: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddControlForm({
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
onSubmit: (data: FormData) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
type: 'TECHNICAL',
|
||||||
|
category: '',
|
||||||
|
owner: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Neue Kontrolle</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="z.B. Zugriffskontrolle"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="Beschreiben Sie die Kontrolle..."
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
||||||
|
<select
|
||||||
|
value={formData.type}
|
||||||
|
onChange={e => setFormData({ ...formData, type: e.target.value as ControlType })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
>
|
||||||
|
<option value="TECHNICAL">Technisch</option>
|
||||||
|
<option value="ORGANIZATIONAL">Organisatorisch</option>
|
||||||
|
<option value="PHYSICAL">Physisch</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.category}
|
||||||
|
onChange={e => setFormData({ ...formData, category: e.target.value })}
|
||||||
|
placeholder="z.B. Zutrittskontrolle"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlich</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.owner}
|
||||||
|
onChange={e => setFormData({ ...formData, owner: e.target.value })}
|
||||||
|
placeholder="z.B. IT Security"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex items-center justify-end gap-3">
|
||||||
|
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSubmit(formData)}
|
||||||
|
disabled={!formData.name}
|
||||||
|
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Hinzufuegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
admin-compliance/app/sdk/controls/_components/ControlCard.tsx
Normal file
163
admin-compliance/app/sdk/controls/_components/ControlCard.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { DisplayControl, DisplayControlType, DisplayCategory, DisplayStatus } from '../_types'
|
||||||
|
import type { ImplementationStatus } from '@/lib/sdk'
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<DisplayControlType, string> = {
|
||||||
|
preventive: 'bg-blue-100 text-blue-700',
|
||||||
|
detective: 'bg-purple-100 text-purple-700',
|
||||||
|
corrective: 'bg-orange-100 text-orange-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS: Record<DisplayCategory, string> = {
|
||||||
|
technical: 'bg-green-100 text-green-700',
|
||||||
|
organizational: 'bg-yellow-100 text-yellow-700',
|
||||||
|
physical: 'bg-gray-100 text-gray-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<DisplayStatus, string> = {
|
||||||
|
implemented: 'border-green-200 bg-green-50',
|
||||||
|
partial: 'border-yellow-200 bg-yellow-50',
|
||||||
|
planned: 'border-blue-200 bg-blue-50',
|
||||||
|
'not-implemented': 'border-red-200 bg-red-50',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<DisplayStatus, string> = {
|
||||||
|
implemented: 'Implementiert',
|
||||||
|
partial: 'Teilweise',
|
||||||
|
planned: 'Geplant',
|
||||||
|
'not-implemented': 'Nicht implementiert',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ControlCard({
|
||||||
|
control,
|
||||||
|
onStatusChange,
|
||||||
|
onEffectivenessChange,
|
||||||
|
onLinkEvidence,
|
||||||
|
}: {
|
||||||
|
control: DisplayControl
|
||||||
|
onStatusChange: (status: ImplementationStatus) => void
|
||||||
|
onEffectivenessChange: (effectivenessPercent: number) => void
|
||||||
|
onLinkEvidence: () => void
|
||||||
|
}) {
|
||||||
|
const [showEffectivenessSlider, setShowEffectivenessSlider] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-xl border-2 p-6 ${STATUS_COLORS[control.displayStatus]}`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded font-mono">{control.code}</span>
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${TYPE_COLORS[control.displayType]}`}>
|
||||||
|
{control.displayType === 'preventive' ? 'Praeventiv' :
|
||||||
|
control.displayType === 'detective' ? 'Detektiv' : 'Korrektiv'}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-1 text-xs rounded-full ${CATEGORY_COLORS[control.displayCategory]}`}>
|
||||||
|
{control.displayCategory === 'technical' ? 'Technisch' :
|
||||||
|
control.displayCategory === 'organizational' ? 'Organisatorisch' : 'Physisch'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{control.name}</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{control.description}</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={control.implementationStatus}
|
||||||
|
onChange={(e) => onStatusChange(e.target.value as ImplementationStatus)}
|
||||||
|
className={`px-3 py-1 text-sm rounded-full border ${STATUS_COLORS[control.displayStatus]}`}
|
||||||
|
>
|
||||||
|
<option value="NOT_IMPLEMENTED">Nicht implementiert</option>
|
||||||
|
<option value="PARTIAL">Teilweise</option>
|
||||||
|
<option value="IMPLEMENTED">Implementiert</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between text-sm mb-1 cursor-pointer"
|
||||||
|
onClick={() => setShowEffectivenessSlider(!showEffectivenessSlider)}
|
||||||
|
>
|
||||||
|
<span className="text-gray-500">Wirksamkeit</span>
|
||||||
|
<span className="font-medium">{control.effectivenessPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all ${
|
||||||
|
control.effectivenessPercent >= 80 ? 'bg-green-500' :
|
||||||
|
control.effectivenessPercent >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${control.effectivenessPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{showEffectivenessSlider && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={100} value={control.effectivenessPercent}
|
||||||
|
onChange={(e) => onEffectivenessChange(Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||||
|
<div className="text-gray-500">
|
||||||
|
<span>Verantwortlich: </span>
|
||||||
|
<span className="font-medium text-gray-700">{control.owner || 'Nicht zugewiesen'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500">Letzte Pruefung: {control.lastReview.toLocaleDateString('de-DE')}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{control.linkedRequirements.slice(0, 3).map(req => (
|
||||||
|
<span key={req} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{req}</span>
|
||||||
|
))}
|
||||||
|
{control.linkedRequirements.length > 3 && (
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">+{control.linkedRequirements.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-1 text-xs rounded-full ${
|
||||||
|
control.displayStatus === 'implemented' ? 'bg-green-100 text-green-700' :
|
||||||
|
control.displayStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
control.displayStatus === 'planned' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{STATUS_LABELS[control.displayStatus]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{control.linkedEvidence.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||||
|
<span className="text-xs text-gray-500 mb-1 block">
|
||||||
|
Nachweise: {control.linkedEvidence.length}
|
||||||
|
{(() => {
|
||||||
|
const e2plus = control.linkedEvidence.filter((ev: { confidenceLevel?: string }) =>
|
||||||
|
ev.confidenceLevel && ['E2', 'E3', 'E4'].includes(ev.confidenceLevel)
|
||||||
|
).length
|
||||||
|
return e2plus > 0 ? ` (${e2plus} E2+)` : ''
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{control.linkedEvidence.map(ev => (
|
||||||
|
<span key={ev.id} className={`px-2 py-0.5 text-xs rounded ${
|
||||||
|
ev.status === 'valid' ? 'bg-green-50 text-green-700' :
|
||||||
|
ev.status === 'expired' ? 'bg-red-50 text-red-700' : 'bg-yellow-50 text-yellow-700'
|
||||||
|
}`}>
|
||||||
|
{ev.title}
|
||||||
|
{(ev as { confidenceLevel?: string }).confidenceLevel && (
|
||||||
|
<span className="ml-1 opacity-70">({(ev as { confidenceLevel?: string }).confidenceLevel})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100">
|
||||||
|
<button onClick={onLinkEvidence} className="text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||||
|
Evidence verknuepfen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
admin-compliance/app/sdk/controls/_components/FilterBar.tsx
Normal file
37
admin-compliance/app/sdk/controls/_components/FilterBar.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const FILTERS = ['all', 'implemented', 'partial', 'not-implemented', 'technical', 'organizational', 'preventive', 'detective']
|
||||||
|
|
||||||
|
const FILTER_LABELS: Record<string, string> = {
|
||||||
|
all: 'Alle',
|
||||||
|
implemented: 'Implementiert',
|
||||||
|
partial: 'Teilweise',
|
||||||
|
'not-implemented': 'Offen',
|
||||||
|
technical: 'Technisch',
|
||||||
|
organizational: 'Organisatorisch',
|
||||||
|
preventive: 'Praeventiv',
|
||||||
|
detective: 'Detektiv',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterBar({
|
||||||
|
filter,
|
||||||
|
onFilterChange,
|
||||||
|
}: {
|
||||||
|
filter: string
|
||||||
|
onFilterChange: (f: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-sm text-gray-500">Filter:</span>
|
||||||
|
{FILTERS.map(f => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => onFilterChange(f)}
|
||||||
|
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||||
|
filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{FILTER_LABELS[f]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export function LoadingSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="h-5 w-20 bg-gray-200 rounded" />
|
||||||
|
<div className="h-5 w-16 bg-gray-200 rounded-full" />
|
||||||
|
<div className="h-5 w-16 bg-gray-200 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
|
||||||
|
<div className="h-4 w-full bg-gray-100 rounded" />
|
||||||
|
<div className="mt-4 h-2 bg-gray-200 rounded-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
135
admin-compliance/app/sdk/controls/_components/RAGPanel.tsx
Normal file
135
admin-compliance/app/sdk/controls/_components/RAGPanel.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { RAGControlSuggestion } from '../_types'
|
||||||
|
|
||||||
|
export function RAGPanel({
|
||||||
|
selectedRequirementId,
|
||||||
|
onSelectedRequirementIdChange,
|
||||||
|
requirements,
|
||||||
|
onSuggestControls,
|
||||||
|
ragLoading,
|
||||||
|
ragSuggestions,
|
||||||
|
onAddSuggestion,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
selectedRequirementId: string
|
||||||
|
onSelectedRequirementIdChange: (id: string) => void
|
||||||
|
requirements: { id: string; title?: string }[]
|
||||||
|
onSuggestControls: () => void
|
||||||
|
ragLoading: boolean
|
||||||
|
ragSuggestions: RAGControlSuggestion[]
|
||||||
|
onAddSuggestion: (s: RAGControlSuggestion) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-purple-50 border border-purple-200 rounded-xl p-6">
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-purple-900">KI-Controls aus RAG vorschlagen</h3>
|
||||||
|
<p className="text-sm text-purple-700 mt-1">
|
||||||
|
Geben Sie eine Anforderungs-ID ein. Das KI-System analysiert die Anforderung mit Hilfe des RAG-Corpus
|
||||||
|
und schlaegt passende Controls vor.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-purple-400 hover:text-purple-600 ml-4">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={selectedRequirementId}
|
||||||
|
onChange={e => onSelectedRequirementIdChange(e.target.value)}
|
||||||
|
placeholder="Anforderungs-UUID eingeben..."
|
||||||
|
className="flex-1 px-4 py-2 border border-purple-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white"
|
||||||
|
/>
|
||||||
|
{requirements.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={selectedRequirementId}
|
||||||
|
onChange={e => onSelectedRequirementIdChange(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-purple-300 rounded-lg bg-white text-sm focus:ring-2 focus:ring-purple-500"
|
||||||
|
>
|
||||||
|
<option value="">Aus Liste waehlen...</option>
|
||||||
|
{requirements.slice(0, 20).map(r => (
|
||||||
|
<option key={r.id} value={r.id}>{r.id.substring(0, 8)}... — {r.title?.substring(0, 40)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onSuggestControls}
|
||||||
|
disabled={ragLoading || !selectedRequirementId}
|
||||||
|
className={`flex items-center gap-2 px-5 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
ragLoading || !selectedRequirementId
|
||||||
|
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ragLoading ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
Analysiere...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||||
|
</svg>
|
||||||
|
Vorschlaege generieren
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ragSuggestions.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-sm font-semibold text-purple-800">{ragSuggestions.length} Vorschlaege gefunden:</h4>
|
||||||
|
{ragSuggestions.map((suggestion) => (
|
||||||
|
<div key={suggestion.control_id} className="bg-white border border-purple-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded font-mono">
|
||||||
|
{suggestion.control_id}
|
||||||
|
</span>
|
||||||
|
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">{suggestion.domain}</span>
|
||||||
|
<span className="text-xs text-gray-500">Konfidenz: {Math.round(suggestion.confidence_score * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<h5 className="font-semibold text-gray-900">{suggestion.title}</h5>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{suggestion.description}</p>
|
||||||
|
{suggestion.pass_criteria && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
<span className="font-medium">Erfolgskriterium:</span> {suggestion.pass_criteria}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{suggestion.is_automated && (
|
||||||
|
<span className="mt-1 inline-block px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded">
|
||||||
|
Automatisierbar {suggestion.automation_tool ? `(${suggestion.automation_tool})` : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onAddSuggestion(suggestion)}
|
||||||
|
className="flex-shrink-0 flex items-center gap-1 px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Hinzufuegen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!ragLoading && ragSuggestions.length === 0 && selectedRequirementId && (
|
||||||
|
<p className="text-sm text-purple-600 italic">
|
||||||
|
Klicken Sie auf "Vorschlaege generieren", um KI-Controls abzurufen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
admin-compliance/app/sdk/controls/_components/StatsCards.tsx
Normal file
32
admin-compliance/app/sdk/controls/_components/StatsCards.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export function StatsCards({
|
||||||
|
total,
|
||||||
|
implementedCount,
|
||||||
|
avgEffectiveness,
|
||||||
|
partialCount,
|
||||||
|
}: {
|
||||||
|
total: number
|
||||||
|
implementedCount: number
|
||||||
|
avgEffectiveness: number
|
||||||
|
partialCount: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
|
<div className="text-sm text-gray-500">Gesamt</div>
|
||||||
|
<div className="text-3xl font-bold text-gray-900">{total}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||||
|
<div className="text-sm text-green-600">Implementiert</div>
|
||||||
|
<div className="text-3xl font-bold text-green-600">{implementedCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-purple-200 p-6">
|
||||||
|
<div className="text-sm text-purple-600">Durchschn. Wirksamkeit</div>
|
||||||
|
<div className="text-3xl font-bold text-purple-600">{avgEffectiveness}%</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
||||||
|
<div className="text-sm text-yellow-600">Teilweise</div>
|
||||||
|
<div className="text-3xl font-bold text-yellow-600">{partialCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export function TransitionErrorBanner({
|
||||||
|
controlId,
|
||||||
|
violations,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
controlId: string
|
||||||
|
violations: string[]
|
||||||
|
onDismiss: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-orange-800">Status-Transition blockiert ({controlId})</h4>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{violations.map((v, i) => (
|
||||||
|
<li key={i} className="text-sm text-orange-700 flex items-start gap-2">
|
||||||
|
<span className="text-orange-400 mt-0.5">•</span>
|
||||||
|
<span>{v}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<a href="/sdk/evidence" className="mt-2 inline-block text-sm text-purple-600 hover:text-purple-700 font-medium">
|
||||||
|
Evidence hinzufuegen →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onDismiss} className="text-orange-400 hover:text-orange-600 ml-4">
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
197
admin-compliance/app/sdk/controls/_hooks/useControlsData.ts
Normal file
197
admin-compliance/app/sdk/controls/_hooks/useControlsData.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSDK, Control as SDKControl, ControlType, ImplementationStatus } from '@/lib/sdk'
|
||||||
|
import { mapControlTypeToDisplay, mapStatusToDisplay } from '../_types'
|
||||||
|
import type { DisplayControl, RAGControlSuggestion } from '../_types'
|
||||||
|
|
||||||
|
export function useControlsData() {
|
||||||
|
const { state, dispatch } = useSDK()
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
|
||||||
|
const [evidenceMap, setEvidenceMap] = useState<Record<string, { id: string; title: string; status: string }[]>>({})
|
||||||
|
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
|
||||||
|
|
||||||
|
const fetchEvidenceForControls = async (_controlIds: string[]) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/compliance/evidence')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const allEvidence = data.evidence || data
|
||||||
|
if (Array.isArray(allEvidence)) {
|
||||||
|
const map: Record<string, { id: string; title: string; status: string; confidenceLevel?: string }[]> = {}
|
||||||
|
for (const ev of allEvidence) {
|
||||||
|
const ctrlId = ev.control_id || ''
|
||||||
|
if (!map[ctrlId]) map[ctrlId] = []
|
||||||
|
map[ctrlId].push({
|
||||||
|
id: ev.id,
|
||||||
|
title: ev.title || ev.name || 'Nachweis',
|
||||||
|
status: ev.status || 'pending',
|
||||||
|
confidenceLevel: ev.confidence_level || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setEvidenceMap(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchControls = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const res = await fetch('/api/sdk/v1/compliance/controls')
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
const backendControls = data.controls || data
|
||||||
|
if (Array.isArray(backendControls) && backendControls.length > 0) {
|
||||||
|
const mapped: SDKControl[] = backendControls.map((c: Record<string, unknown>) => ({
|
||||||
|
id: (c.control_id || c.id) as string,
|
||||||
|
name: (c.name || c.title || '') as string,
|
||||||
|
description: (c.description || '') as string,
|
||||||
|
type: ((c.type || c.control_type || 'TECHNICAL') as string).toUpperCase() as ControlType,
|
||||||
|
category: (c.category || '') as string,
|
||||||
|
implementationStatus: ((c.implementation_status || c.status || 'NOT_IMPLEMENTED') as string).toUpperCase() as ImplementationStatus,
|
||||||
|
effectiveness: (c.effectiveness || 'LOW') as 'LOW' | 'MEDIUM' | 'HIGH',
|
||||||
|
evidence: (c.evidence || []) as string[],
|
||||||
|
owner: (c.owner || null) as string | null,
|
||||||
|
dueDate: c.due_date ? new Date(c.due_date as string) : null,
|
||||||
|
}))
|
||||||
|
dispatch({ type: 'SET_STATE', payload: { controls: mapped } })
|
||||||
|
setError(null)
|
||||||
|
fetchEvidenceForControls(mapped.map(c => c.id))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// API not available — show empty state
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchControls()
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const displayControls: DisplayControl[] = state.controls.map(ctrl => {
|
||||||
|
const effectivenessPercent = effectivenessMap[ctrl.id] ??
|
||||||
|
(ctrl.implementationStatus === 'IMPLEMENTED' ? 85 :
|
||||||
|
ctrl.implementationStatus === 'PARTIAL' ? 50 : 0)
|
||||||
|
return {
|
||||||
|
id: ctrl.id,
|
||||||
|
name: ctrl.name,
|
||||||
|
description: ctrl.description,
|
||||||
|
type: ctrl.type,
|
||||||
|
category: ctrl.category,
|
||||||
|
implementationStatus: ctrl.implementationStatus,
|
||||||
|
evidence: ctrl.evidence,
|
||||||
|
owner: ctrl.owner,
|
||||||
|
dueDate: ctrl.dueDate,
|
||||||
|
code: ctrl.id,
|
||||||
|
displayType: 'preventive' as const,
|
||||||
|
displayCategory: mapControlTypeToDisplay(ctrl.type),
|
||||||
|
displayStatus: mapStatusToDisplay(ctrl.implementationStatus),
|
||||||
|
effectivenessPercent,
|
||||||
|
linkedRequirements: [],
|
||||||
|
linkedEvidence: evidenceMap[ctrl.id] || [],
|
||||||
|
lastReview: new Date(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleStatusChange = async (controlId: string, newStatus: ImplementationStatus) => {
|
||||||
|
const oldControl = state.controls.find(c => c.id === controlId)
|
||||||
|
const oldStatus = oldControl?.implementationStatus
|
||||||
|
|
||||||
|
dispatch({ type: 'UPDATE_CONTROL', payload: { id: controlId, data: { implementationStatus: newStatus } } })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ implementation_status: newStatus }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
if (oldStatus) {
|
||||||
|
dispatch({ type: 'UPDATE_CONTROL', payload: { id: controlId, data: { implementationStatus: oldStatus } } })
|
||||||
|
}
|
||||||
|
const err = await res.json().catch(() => ({ detail: 'Status-Aenderung fehlgeschlagen' }))
|
||||||
|
if (res.status === 409 && err.detail?.violations) {
|
||||||
|
setTransitionError({ controlId, violations: err.detail.violations })
|
||||||
|
} else {
|
||||||
|
const msg = typeof err.detail === 'string' ? err.detail : err.detail?.error || 'Status-Aenderung fehlgeschlagen'
|
||||||
|
setError(msg)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTransitionError(prev => prev?.controlId === controlId ? null : prev)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (oldStatus) {
|
||||||
|
dispatch({ type: 'UPDATE_CONTROL', payload: { id: controlId, data: { implementationStatus: oldStatus } } })
|
||||||
|
}
|
||||||
|
setError('Netzwerkfehler bei Status-Aenderung')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEffectivenessChange = async (controlId: string, effectiveness: number) => {
|
||||||
|
setEffectivenessMap(prev => ({ ...prev, [controlId]: effectiveness }))
|
||||||
|
try {
|
||||||
|
await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ effectiveness_score: effectiveness }),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddControl = (data: { name: string; description: string; type: ControlType; category: string; owner: string }) => {
|
||||||
|
const newControl: SDKControl = {
|
||||||
|
id: `ctrl-${Date.now()}`,
|
||||||
|
name: data.name,
|
||||||
|
description: data.description,
|
||||||
|
type: data.type,
|
||||||
|
category: data.category,
|
||||||
|
implementationStatus: 'NOT_IMPLEMENTED',
|
||||||
|
effectiveness: 'LOW',
|
||||||
|
evidence: [],
|
||||||
|
owner: data.owner || null,
|
||||||
|
dueDate: null,
|
||||||
|
}
|
||||||
|
dispatch({ type: 'ADD_CONTROL', payload: newControl })
|
||||||
|
}
|
||||||
|
|
||||||
|
const addSuggestedControl = (suggestion: RAGControlSuggestion) => {
|
||||||
|
const newControl: SDKControl = {
|
||||||
|
id: `rag-${suggestion.control_id}-${Date.now()}`,
|
||||||
|
name: suggestion.title,
|
||||||
|
description: suggestion.description,
|
||||||
|
type: 'TECHNICAL',
|
||||||
|
category: suggestion.domain,
|
||||||
|
implementationStatus: 'NOT_IMPLEMENTED',
|
||||||
|
effectiveness: 'LOW',
|
||||||
|
evidence: [],
|
||||||
|
owner: null,
|
||||||
|
dueDate: null,
|
||||||
|
}
|
||||||
|
dispatch({ type: 'ADD_CONTROL', payload: newControl })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
setError,
|
||||||
|
effectivenessMap,
|
||||||
|
evidenceMap,
|
||||||
|
displayControls,
|
||||||
|
transitionError,
|
||||||
|
setTransitionError,
|
||||||
|
handleStatusChange,
|
||||||
|
handleEffectivenessChange,
|
||||||
|
handleAddControl,
|
||||||
|
addSuggestedControl,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { RAGControlSuggestion } from '../_types'
|
||||||
|
|
||||||
|
export function useRAGSuggestions(setError: (msg: string | null) => void) {
|
||||||
|
const [ragLoading, setRagLoading] = useState(false)
|
||||||
|
const [ragSuggestions, setRagSuggestions] = useState<RAGControlSuggestion[]>([])
|
||||||
|
const [showRagPanel, setShowRagPanel] = useState(false)
|
||||||
|
const [selectedRequirementId, setSelectedRequirementId] = useState<string>('')
|
||||||
|
|
||||||
|
const suggestControlsFromRAG = async () => {
|
||||||
|
if (!selectedRequirementId) {
|
||||||
|
setError('Bitte eine Anforderungs-ID eingeben.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRagLoading(true)
|
||||||
|
setRagSuggestions([])
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sdk/v1/compliance/ai/suggest-controls', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ requirement_id: selectedRequirementId }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = await res.text()
|
||||||
|
throw new Error(msg || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
const data = await res.json()
|
||||||
|
setRagSuggestions(data.suggestions || [])
|
||||||
|
setShowRagPanel(true)
|
||||||
|
} catch (e) {
|
||||||
|
setError(`KI-Vorschlaege fehlgeschlagen: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`)
|
||||||
|
} finally {
|
||||||
|
setRagLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeSuggestion = (controlId: string) => {
|
||||||
|
setRagSuggestions(prev => prev.filter(s => s.control_id !== controlId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ragLoading,
|
||||||
|
ragSuggestions,
|
||||||
|
showRagPanel,
|
||||||
|
setShowRagPanel,
|
||||||
|
selectedRequirementId,
|
||||||
|
setSelectedRequirementId,
|
||||||
|
suggestControlsFromRAG,
|
||||||
|
removeSuggestion,
|
||||||
|
}
|
||||||
|
}
|
||||||
56
admin-compliance/app/sdk/controls/_types.ts
Normal file
56
admin-compliance/app/sdk/controls/_types.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { ControlType, ImplementationStatus } from '@/lib/sdk'
|
||||||
|
|
||||||
|
export type DisplayControlType = 'preventive' | 'detective' | 'corrective'
|
||||||
|
export type DisplayCategory = 'technical' | 'organizational' | 'physical'
|
||||||
|
export type DisplayStatus = 'implemented' | 'partial' | 'planned' | 'not-implemented'
|
||||||
|
|
||||||
|
export interface DisplayControl {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
type: ControlType
|
||||||
|
category: string
|
||||||
|
implementationStatus: ImplementationStatus
|
||||||
|
evidence: string[]
|
||||||
|
owner: string | null
|
||||||
|
dueDate: Date | null
|
||||||
|
code: string
|
||||||
|
displayType: DisplayControlType
|
||||||
|
displayCategory: DisplayCategory
|
||||||
|
displayStatus: DisplayStatus
|
||||||
|
effectivenessPercent: number
|
||||||
|
linkedRequirements: string[]
|
||||||
|
linkedEvidence: { id: string; title: string; status: string }[]
|
||||||
|
lastReview: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RAGControlSuggestion {
|
||||||
|
control_id: string
|
||||||
|
domain: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
pass_criteria: string
|
||||||
|
implementation_guidance?: string
|
||||||
|
is_automated: boolean
|
||||||
|
automation_tool?: string
|
||||||
|
priority: number
|
||||||
|
confidence_score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapControlTypeToDisplay(type: ControlType): DisplayCategory {
|
||||||
|
switch (type) {
|
||||||
|
case 'TECHNICAL': return 'technical'
|
||||||
|
case 'ORGANIZATIONAL': return 'organizational'
|
||||||
|
case 'PHYSICAL': return 'physical'
|
||||||
|
default: return 'technical'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapStatusToDisplay(status: ImplementationStatus): DisplayStatus {
|
||||||
|
switch (status) {
|
||||||
|
case 'IMPLEMENTED': return 'implemented'
|
||||||
|
case 'PARTIAL': return 'partial'
|
||||||
|
case 'NOT_IMPLEMENTED': return 'not-implemented'
|
||||||
|
default: return 'not-implemented'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,538 +1,52 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useSDK, Control as SDKControl, ControlType, ImplementationStatus } from '@/lib/sdk'
|
|
||||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||||
|
import { useControlsData } from './_hooks/useControlsData'
|
||||||
// =============================================================================
|
import { useRAGSuggestions } from './_hooks/useRAGSuggestions'
|
||||||
// TYPES
|
import { ControlCard } from './_components/ControlCard'
|
||||||
// =============================================================================
|
import { AddControlForm } from './_components/AddControlForm'
|
||||||
|
import { LoadingSkeleton } from './_components/LoadingSkeleton'
|
||||||
type DisplayControlType = 'preventive' | 'detective' | 'corrective'
|
import { TransitionErrorBanner } from './_components/TransitionErrorBanner'
|
||||||
type DisplayCategory = 'technical' | 'organizational' | 'physical'
|
import { StatsCards } from './_components/StatsCards'
|
||||||
type DisplayStatus = 'implemented' | 'partial' | 'planned' | 'not-implemented'
|
import { FilterBar } from './_components/FilterBar'
|
||||||
|
import { RAGPanel } from './_components/RAGPanel'
|
||||||
interface DisplayControl {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
type: ControlType
|
|
||||||
category: string
|
|
||||||
implementationStatus: ImplementationStatus
|
|
||||||
evidence: string[]
|
|
||||||
owner: string | null
|
|
||||||
dueDate: Date | null
|
|
||||||
code: string
|
|
||||||
displayType: DisplayControlType
|
|
||||||
displayCategory: DisplayCategory
|
|
||||||
displayStatus: DisplayStatus
|
|
||||||
effectivenessPercent: number
|
|
||||||
linkedRequirements: string[]
|
|
||||||
linkedEvidence: { id: string; title: string; status: string }[]
|
|
||||||
lastReview: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// HELPER FUNCTIONS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function mapControlTypeToDisplay(type: ControlType): DisplayCategory {
|
|
||||||
switch (type) {
|
|
||||||
case 'TECHNICAL': return 'technical'
|
|
||||||
case 'ORGANIZATIONAL': return 'organizational'
|
|
||||||
case 'PHYSICAL': return 'physical'
|
|
||||||
default: return 'technical'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapStatusToDisplay(status: ImplementationStatus): DisplayStatus {
|
|
||||||
switch (status) {
|
|
||||||
case 'IMPLEMENTED': return 'implemented'
|
|
||||||
case 'PARTIAL': return 'partial'
|
|
||||||
case 'NOT_IMPLEMENTED': return 'not-implemented'
|
|
||||||
default: return 'not-implemented'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// COMPONENTS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function ControlCard({
|
|
||||||
control,
|
|
||||||
onStatusChange,
|
|
||||||
onEffectivenessChange,
|
|
||||||
onLinkEvidence,
|
|
||||||
}: {
|
|
||||||
control: DisplayControl
|
|
||||||
onStatusChange: (status: ImplementationStatus) => void
|
|
||||||
onEffectivenessChange: (effectivenessPercent: number) => void
|
|
||||||
onLinkEvidence: () => void
|
|
||||||
}) {
|
|
||||||
const [showEffectivenessSlider, setShowEffectivenessSlider] = useState(false)
|
|
||||||
|
|
||||||
const typeColors = {
|
|
||||||
preventive: 'bg-blue-100 text-blue-700',
|
|
||||||
detective: 'bg-purple-100 text-purple-700',
|
|
||||||
corrective: 'bg-orange-100 text-orange-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryColors = {
|
|
||||||
technical: 'bg-green-100 text-green-700',
|
|
||||||
organizational: 'bg-yellow-100 text-yellow-700',
|
|
||||||
physical: 'bg-gray-100 text-gray-700',
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusColors = {
|
|
||||||
implemented: 'border-green-200 bg-green-50',
|
|
||||||
partial: 'border-yellow-200 bg-yellow-50',
|
|
||||||
planned: 'border-blue-200 bg-blue-50',
|
|
||||||
'not-implemented': 'border-red-200 bg-red-50',
|
|
||||||
}
|
|
||||||
|
|
||||||
const statusLabels = {
|
|
||||||
implemented: 'Implementiert',
|
|
||||||
partial: 'Teilweise',
|
|
||||||
planned: 'Geplant',
|
|
||||||
'not-implemented': 'Nicht implementiert',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`bg-white rounded-xl border-2 p-6 ${statusColors[control.displayStatus]}`}>
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded font-mono">
|
|
||||||
{control.code}
|
|
||||||
</span>
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${typeColors[control.displayType]}`}>
|
|
||||||
{control.displayType === 'preventive' ? 'Praeventiv' :
|
|
||||||
control.displayType === 'detective' ? 'Detektiv' : 'Korrektiv'}
|
|
||||||
</span>
|
|
||||||
<span className={`px-2 py-1 text-xs rounded-full ${categoryColors[control.displayCategory]}`}>
|
|
||||||
{control.displayCategory === 'technical' ? 'Technisch' :
|
|
||||||
control.displayCategory === 'organizational' ? 'Organisatorisch' : 'Physisch'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{control.name}</h3>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">{control.description}</p>
|
|
||||||
</div>
|
|
||||||
<select
|
|
||||||
value={control.implementationStatus}
|
|
||||||
onChange={(e) => onStatusChange(e.target.value as ImplementationStatus)}
|
|
||||||
className={`px-3 py-1 text-sm rounded-full border ${statusColors[control.displayStatus]}`}
|
|
||||||
>
|
|
||||||
<option value="NOT_IMPLEMENTED">Nicht implementiert</option>
|
|
||||||
<option value="PARTIAL">Teilweise</option>
|
|
||||||
<option value="IMPLEMENTED">Implementiert</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between text-sm mb-1 cursor-pointer"
|
|
||||||
onClick={() => setShowEffectivenessSlider(!showEffectivenessSlider)}
|
|
||||||
>
|
|
||||||
<span className="text-gray-500">Wirksamkeit</span>
|
|
||||||
<span className="font-medium">{control.effectivenessPercent}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`h-full rounded-full transition-all ${
|
|
||||||
control.effectivenessPercent >= 80 ? 'bg-green-500' :
|
|
||||||
control.effectivenessPercent >= 50 ? 'bg-yellow-500' : 'bg-red-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${control.effectivenessPercent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{showEffectivenessSlider && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={control.effectivenessPercent}
|
|
||||||
onChange={(e) => onEffectivenessChange(Number(e.target.value))}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
|
||||||
<div className="text-gray-500">
|
|
||||||
<span>Verantwortlich: </span>
|
|
||||||
<span className="font-medium text-gray-700">{control.owner || 'Nicht zugewiesen'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-gray-500">
|
|
||||||
Letzte Pruefung: {control.lastReview.toLocaleDateString('de-DE')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
|
||||||
{control.linkedRequirements.slice(0, 3).map(req => (
|
|
||||||
<span key={req} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
|
||||||
{req}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{control.linkedRequirements.length > 3 && (
|
|
||||||
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
|
||||||
+{control.linkedRequirements.length - 3}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={`px-3 py-1 text-xs rounded-full ${
|
|
||||||
control.displayStatus === 'implemented' ? 'bg-green-100 text-green-700' :
|
|
||||||
control.displayStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' :
|
|
||||||
control.displayStatus === 'planned' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'
|
|
||||||
}`}>
|
|
||||||
{statusLabels[control.displayStatus]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Linked Evidence */}
|
|
||||||
{control.linkedEvidence.length > 0 && (
|
|
||||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
|
||||||
<span className="text-xs text-gray-500 mb-1 block">
|
|
||||||
Nachweise: {control.linkedEvidence.length}
|
|
||||||
{(() => {
|
|
||||||
const e2plus = control.linkedEvidence.filter((ev: { confidenceLevel?: string }) =>
|
|
||||||
ev.confidenceLevel && ['E2', 'E3', 'E4'].includes(ev.confidenceLevel)
|
|
||||||
).length
|
|
||||||
return e2plus > 0 ? ` (${e2plus} E2+)` : ''
|
|
||||||
})()}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
|
||||||
{control.linkedEvidence.map(ev => (
|
|
||||||
<span key={ev.id} className={`px-2 py-0.5 text-xs rounded ${
|
|
||||||
ev.status === 'valid' ? 'bg-green-50 text-green-700' :
|
|
||||||
ev.status === 'expired' ? 'bg-red-50 text-red-700' :
|
|
||||||
'bg-yellow-50 text-yellow-700'
|
|
||||||
}`}>
|
|
||||||
{ev.title}
|
|
||||||
{(ev as { confidenceLevel?: string }).confidenceLevel && (
|
|
||||||
<span className="ml-1 opacity-70">({(ev as { confidenceLevel?: string }).confidenceLevel})</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-gray-100">
|
|
||||||
<button
|
|
||||||
onClick={onLinkEvidence}
|
|
||||||
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
|
|
||||||
>
|
|
||||||
Evidence verknuepfen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddControlForm({
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
}: {
|
|
||||||
onSubmit: (data: { name: string; description: string; type: ControlType; category: string; owner: string }) => void
|
|
||||||
onCancel: () => void
|
|
||||||
}) {
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
type: 'TECHNICAL' as ControlType,
|
|
||||||
category: '',
|
|
||||||
owner: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Neue Kontrolle</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="z.B. Zugriffskontrolle"
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={e => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
placeholder="Beschreiben Sie die Kontrolle..."
|
|
||||||
rows={2}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
|
||||||
<select
|
|
||||||
value={formData.type}
|
|
||||||
onChange={e => setFormData({ ...formData, type: e.target.value as ControlType })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="TECHNICAL">Technisch</option>
|
|
||||||
<option value="ORGANIZATIONAL">Organisatorisch</option>
|
|
||||||
<option value="PHYSICAL">Physisch</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.category}
|
|
||||||
onChange={e => setFormData({ ...formData, category: e.target.value })}
|
|
||||||
placeholder="z.B. Zutrittskontrolle"
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verantwortlich</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.owner}
|
|
||||||
onChange={e => setFormData({ ...formData, owner: e.target.value })}
|
|
||||||
placeholder="z.B. IT Security"
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 flex items-center justify-end gap-3">
|
|
||||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onSubmit(formData)}
|
|
||||||
disabled={!formData.name}
|
|
||||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
|
||||||
formData.name ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Hinzufuegen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoadingSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[1, 2, 3].map(i => (
|
|
||||||
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<div className="h-5 w-20 bg-gray-200 rounded" />
|
|
||||||
<div className="h-5 w-16 bg-gray-200 rounded-full" />
|
|
||||||
<div className="h-5 w-16 bg-gray-200 rounded-full" />
|
|
||||||
</div>
|
|
||||||
<div className="h-6 w-3/4 bg-gray-200 rounded mb-2" />
|
|
||||||
<div className="h-4 w-full bg-gray-100 rounded" />
|
|
||||||
<div className="mt-4 h-2 bg-gray-200 rounded-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// MAIN PAGE
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// RAG SUGGESTION TYPES
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
interface RAGControlSuggestion {
|
|
||||||
control_id: string
|
|
||||||
domain: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
pass_criteria: string
|
|
||||||
implementation_guidance?: string
|
|
||||||
is_automated: boolean
|
|
||||||
automation_tool?: string
|
|
||||||
priority: number
|
|
||||||
confidence_score: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// MAIN PAGE
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
function TransitionErrorBanner({
|
|
||||||
controlId,
|
|
||||||
violations,
|
|
||||||
onDismiss,
|
|
||||||
}: {
|
|
||||||
controlId: string
|
|
||||||
violations: string[]
|
|
||||||
onDismiss: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 bg-orange-50 border border-orange-200 rounded-lg">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<svg className="w-5 h-5 text-orange-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-orange-800">
|
|
||||||
Status-Transition blockiert ({controlId})
|
|
||||||
</h4>
|
|
||||||
<ul className="mt-2 space-y-1">
|
|
||||||
{violations.map((v, i) => (
|
|
||||||
<li key={i} className="text-sm text-orange-700 flex items-start gap-2">
|
|
||||||
<span className="text-orange-400 mt-0.5">•</span>
|
|
||||||
<span>{v}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<a href="/sdk/evidence" className="mt-2 inline-block text-sm text-purple-600 hover:text-purple-700 font-medium">
|
|
||||||
Evidence hinzufuegen →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button onClick={onDismiss} className="text-orange-400 hover:text-orange-600 ml-4">
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ControlsPage() {
|
export default function ControlsPage() {
|
||||||
const { state, dispatch } = useSDK()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
|
||||||
// RAG suggestion state
|
const {
|
||||||
const [ragLoading, setRagLoading] = useState(false)
|
state,
|
||||||
const [ragSuggestions, setRagSuggestions] = useState<RAGControlSuggestion[]>([])
|
loading,
|
||||||
const [showRagPanel, setShowRagPanel] = useState(false)
|
error,
|
||||||
const [selectedRequirementId, setSelectedRequirementId] = useState<string>('')
|
setError,
|
||||||
|
displayControls,
|
||||||
|
transitionError,
|
||||||
|
setTransitionError,
|
||||||
|
handleStatusChange,
|
||||||
|
handleEffectivenessChange,
|
||||||
|
handleAddControl,
|
||||||
|
addSuggestedControl,
|
||||||
|
} = useControlsData()
|
||||||
|
|
||||||
// Transition error from Anti-Fake-Evidence state machine (409 Conflict)
|
const {
|
||||||
const [transitionError, setTransitionError] = useState<{ controlId: string; violations: string[] } | null>(null)
|
ragLoading,
|
||||||
|
ragSuggestions,
|
||||||
// Track effectiveness locally as it's not in the SDK state type
|
showRagPanel,
|
||||||
const [effectivenessMap, setEffectivenessMap] = useState<Record<string, number>>({})
|
setShowRagPanel,
|
||||||
// Track linked evidence per control
|
selectedRequirementId,
|
||||||
const [evidenceMap, setEvidenceMap] = useState<Record<string, { id: string; title: string; status: string }[]>>({})
|
setSelectedRequirementId,
|
||||||
|
suggestControlsFromRAG,
|
||||||
const fetchEvidenceForControls = async (controlIds: string[]) => {
|
removeSuggestion,
|
||||||
try {
|
} = useRAGSuggestions(setError)
|
||||||
const res = await fetch('/api/sdk/v1/compliance/evidence')
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
const allEvidence = data.evidence || data
|
|
||||||
if (Array.isArray(allEvidence)) {
|
|
||||||
const map: Record<string, { id: string; title: string; status: string; confidenceLevel?: string }[]> = {}
|
|
||||||
for (const ev of allEvidence) {
|
|
||||||
const ctrlId = ev.control_id || ''
|
|
||||||
if (!map[ctrlId]) map[ctrlId] = []
|
|
||||||
map[ctrlId].push({
|
|
||||||
id: ev.id,
|
|
||||||
title: ev.title || ev.name || 'Nachweis',
|
|
||||||
status: ev.status || 'pending',
|
|
||||||
confidenceLevel: ev.confidence_level || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
setEvidenceMap(map)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Silently fail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch controls from backend on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchControls = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
const res = await fetch('/api/sdk/v1/compliance/controls')
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json()
|
|
||||||
const backendControls = data.controls || data
|
|
||||||
if (Array.isArray(backendControls) && backendControls.length > 0) {
|
|
||||||
const mapped: SDKControl[] = backendControls.map((c: Record<string, unknown>) => ({
|
|
||||||
id: (c.control_id || c.id) as string,
|
|
||||||
name: (c.name || c.title || '') as string,
|
|
||||||
description: (c.description || '') as string,
|
|
||||||
type: ((c.type || c.control_type || 'TECHNICAL') as string).toUpperCase() as ControlType,
|
|
||||||
category: (c.category || '') as string,
|
|
||||||
implementationStatus: ((c.implementation_status || c.status || 'NOT_IMPLEMENTED') as string).toUpperCase() as ImplementationStatus,
|
|
||||||
effectiveness: (c.effectiveness || 'LOW') as 'LOW' | 'MEDIUM' | 'HIGH',
|
|
||||||
evidence: (c.evidence || []) as string[],
|
|
||||||
owner: (c.owner || null) as string | null,
|
|
||||||
dueDate: c.due_date ? new Date(c.due_date as string) : null,
|
|
||||||
}))
|
|
||||||
dispatch({ type: 'SET_STATE', payload: { controls: mapped } })
|
|
||||||
setError(null)
|
|
||||||
// Fetch evidence for all controls
|
|
||||||
fetchEvidenceForControls(mapped.map(c => c.id))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// API not available — show empty state
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchControls()
|
|
||||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// Convert SDK controls to display controls
|
|
||||||
const displayControls: DisplayControl[] = state.controls.map(ctrl => {
|
|
||||||
const effectivenessPercent = effectivenessMap[ctrl.id] ??
|
|
||||||
(ctrl.implementationStatus === 'IMPLEMENTED' ? 85 :
|
|
||||||
ctrl.implementationStatus === 'PARTIAL' ? 50 : 0)
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: ctrl.id,
|
|
||||||
name: ctrl.name,
|
|
||||||
description: ctrl.description,
|
|
||||||
type: ctrl.type,
|
|
||||||
category: ctrl.category,
|
|
||||||
implementationStatus: ctrl.implementationStatus,
|
|
||||||
evidence: ctrl.evidence,
|
|
||||||
owner: ctrl.owner,
|
|
||||||
dueDate: ctrl.dueDate,
|
|
||||||
code: ctrl.id,
|
|
||||||
displayType: 'preventive' as DisplayControlType,
|
|
||||||
displayCategory: mapControlTypeToDisplay(ctrl.type),
|
|
||||||
displayStatus: mapStatusToDisplay(ctrl.implementationStatus),
|
|
||||||
effectivenessPercent,
|
|
||||||
linkedRequirements: [],
|
|
||||||
linkedEvidence: evidenceMap[ctrl.id] || [],
|
|
||||||
lastReview: new Date(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredControls = filter === 'all'
|
const filteredControls = filter === 'all'
|
||||||
? displayControls
|
? displayControls
|
||||||
: displayControls.filter(c =>
|
: displayControls.filter(c =>
|
||||||
c.displayStatus === filter ||
|
c.displayStatus === filter || c.displayType === filter || c.displayCategory === filter
|
||||||
c.displayType === filter ||
|
|
||||||
c.displayCategory === filter
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const implementedCount = displayControls.filter(c => c.displayStatus === 'implemented').length
|
const implementedCount = displayControls.filter(c => c.displayStatus === 'implemented').length
|
||||||
@@ -541,141 +55,10 @@ export default function ControlsPage() {
|
|||||||
: 0
|
: 0
|
||||||
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
|
const partialCount = displayControls.filter(c => c.displayStatus === 'partial').length
|
||||||
|
|
||||||
const handleStatusChange = async (controlId: string, newStatus: ImplementationStatus) => {
|
|
||||||
// Remember old status for rollback
|
|
||||||
const oldControl = state.controls.find(c => c.id === controlId)
|
|
||||||
const oldStatus = oldControl?.implementationStatus
|
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
dispatch({
|
|
||||||
type: 'UPDATE_CONTROL',
|
|
||||||
payload: { id: controlId, data: { implementationStatus: newStatus } },
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ implementation_status: newStatus }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
// Rollback optimistic update
|
|
||||||
if (oldStatus) {
|
|
||||||
dispatch({
|
|
||||||
type: 'UPDATE_CONTROL',
|
|
||||||
payload: { id: controlId, data: { implementationStatus: oldStatus } },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const err = await res.json().catch(() => ({ detail: 'Status-Aenderung fehlgeschlagen' }))
|
|
||||||
|
|
||||||
if (res.status === 409 && err.detail?.violations) {
|
|
||||||
setTransitionError({ controlId, violations: err.detail.violations })
|
|
||||||
} else {
|
|
||||||
const msg = typeof err.detail === 'string' ? err.detail : err.detail?.error || 'Status-Aenderung fehlgeschlagen'
|
|
||||||
setError(msg)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Clear any previous transition error for this control
|
|
||||||
if (transitionError?.controlId === controlId) {
|
|
||||||
setTransitionError(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Network error — rollback
|
|
||||||
if (oldStatus) {
|
|
||||||
dispatch({
|
|
||||||
type: 'UPDATE_CONTROL',
|
|
||||||
payload: { id: controlId, data: { implementationStatus: oldStatus } },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
setError('Netzwerkfehler bei Status-Aenderung')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEffectivenessChange = async (controlId: string, effectiveness: number) => {
|
|
||||||
setEffectivenessMap(prev => ({ ...prev, [controlId]: effectiveness }))
|
|
||||||
|
|
||||||
// Persist to backend
|
|
||||||
try {
|
|
||||||
await fetch(`/api/sdk/v1/compliance/controls/${controlId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ effectiveness_score: effectiveness }),
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Silently fail — local state is already updated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddControl = (data: { name: string; description: string; type: ControlType; category: string; owner: string }) => {
|
|
||||||
const newControl: SDKControl = {
|
|
||||||
id: `ctrl-${Date.now()}`,
|
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
type: data.type,
|
|
||||||
category: data.category,
|
|
||||||
implementationStatus: 'NOT_IMPLEMENTED',
|
|
||||||
effectiveness: 'LOW',
|
|
||||||
evidence: [],
|
|
||||||
owner: data.owner || null,
|
|
||||||
dueDate: null,
|
|
||||||
}
|
|
||||||
dispatch({ type: 'ADD_CONTROL', payload: newControl })
|
|
||||||
setShowAddForm(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const suggestControlsFromRAG = async () => {
|
|
||||||
if (!selectedRequirementId) {
|
|
||||||
setError('Bitte eine Anforderungs-ID eingeben.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setRagLoading(true)
|
|
||||||
setRagSuggestions([])
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/sdk/v1/compliance/ai/suggest-controls', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ requirement_id: selectedRequirementId }),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
const msg = await res.text()
|
|
||||||
throw new Error(msg || `HTTP ${res.status}`)
|
|
||||||
}
|
|
||||||
const data = await res.json()
|
|
||||||
setRagSuggestions(data.suggestions || [])
|
|
||||||
setShowRagPanel(true)
|
|
||||||
} catch (e) {
|
|
||||||
setError(`KI-Vorschläge fehlgeschlagen: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`)
|
|
||||||
} finally {
|
|
||||||
setRagLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addSuggestedControl = (suggestion: RAGControlSuggestion) => {
|
|
||||||
const newControl: import('@/lib/sdk').Control = {
|
|
||||||
id: `rag-${suggestion.control_id}-${Date.now()}`,
|
|
||||||
name: suggestion.title,
|
|
||||||
description: suggestion.description,
|
|
||||||
type: 'TECHNICAL',
|
|
||||||
category: suggestion.domain,
|
|
||||||
implementationStatus: 'NOT_IMPLEMENTED',
|
|
||||||
effectiveness: 'LOW',
|
|
||||||
evidence: [],
|
|
||||||
owner: null,
|
|
||||||
dueDate: null,
|
|
||||||
}
|
|
||||||
dispatch({ type: 'ADD_CONTROL', payload: newControl })
|
|
||||||
// Remove from suggestions after adding
|
|
||||||
setRagSuggestions(prev => prev.filter(s => s.control_id !== suggestion.control_id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepInfo = STEP_EXPLANATIONS['controls']
|
const stepInfo = STEP_EXPLANATIONS['controls']
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Step Header */}
|
|
||||||
<StepHeader
|
<StepHeader
|
||||||
stepId="controls"
|
stepId="controls"
|
||||||
title={stepInfo.title}
|
title={stepInfo.title}
|
||||||
@@ -705,133 +88,26 @@ export default function ControlsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
{/* Add Form */}
|
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<AddControlForm
|
<AddControlForm
|
||||||
onSubmit={handleAddControl}
|
onSubmit={(data) => { handleAddControl(data); setShowAddForm(false) }}
|
||||||
onCancel={() => setShowAddForm(false)}
|
onCancel={() => setShowAddForm(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* RAG Controls Panel */}
|
|
||||||
{showRagPanel && (
|
{showRagPanel && (
|
||||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-6">
|
<RAGPanel
|
||||||
<div className="flex items-start justify-between mb-4">
|
selectedRequirementId={selectedRequirementId}
|
||||||
<div>
|
onSelectedRequirementIdChange={setSelectedRequirementId}
|
||||||
<h3 className="text-lg font-semibold text-purple-900">KI-Controls aus RAG vorschlagen</h3>
|
requirements={state.requirements}
|
||||||
<p className="text-sm text-purple-700 mt-1">
|
onSuggestControls={suggestControlsFromRAG}
|
||||||
Geben Sie eine Anforderungs-ID ein. Das KI-System analysiert die Anforderung mit Hilfe des RAG-Corpus
|
ragLoading={ragLoading}
|
||||||
und schlägt passende Controls vor.
|
ragSuggestions={ragSuggestions}
|
||||||
</p>
|
onAddSuggestion={(s) => { addSuggestedControl(s); removeSuggestion(s.control_id) }}
|
||||||
</div>
|
onClose={() => setShowRagPanel(false)}
|
||||||
<button onClick={() => setShowRagPanel(false)} className="text-purple-400 hover:text-purple-600 ml-4">
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={selectedRequirementId}
|
|
||||||
onChange={e => setSelectedRequirementId(e.target.value)}
|
|
||||||
placeholder="Anforderungs-UUID eingeben..."
|
|
||||||
className="flex-1 px-4 py-2 border border-purple-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white"
|
|
||||||
/>
|
/>
|
||||||
{state.requirements.length > 0 && (
|
|
||||||
<select
|
|
||||||
value={selectedRequirementId}
|
|
||||||
onChange={e => setSelectedRequirementId(e.target.value)}
|
|
||||||
className="px-3 py-2 border border-purple-300 rounded-lg bg-white text-sm focus:ring-2 focus:ring-purple-500"
|
|
||||||
>
|
|
||||||
<option value="">Aus Liste wählen...</option>
|
|
||||||
{state.requirements.slice(0, 20).map(r => (
|
|
||||||
<option key={r.id} value={r.id}>{r.id.substring(0, 8)}... — {r.title?.substring(0, 40)}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={suggestControlsFromRAG}
|
|
||||||
disabled={ragLoading || !selectedRequirementId}
|
|
||||||
className={`flex items-center gap-2 px-5 py-2 rounded-lg font-medium transition-colors ${
|
|
||||||
ragLoading || !selectedRequirementId
|
|
||||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
||||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{ragLoading ? (
|
|
||||||
<>
|
|
||||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
||||||
Analysiere...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
Vorschläge generieren
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Suggestions */}
|
|
||||||
{ragSuggestions.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-sm font-semibold text-purple-800">{ragSuggestions.length} Vorschläge gefunden:</h4>
|
|
||||||
{ragSuggestions.map((suggestion) => (
|
|
||||||
<div key={suggestion.control_id} className="bg-white border border-purple-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-start justify-between gap-3">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded font-mono">
|
|
||||||
{suggestion.control_id}
|
|
||||||
</span>
|
|
||||||
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded">
|
|
||||||
{suggestion.domain}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
Konfidenz: {Math.round(suggestion.confidence_score * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h5 className="font-semibold text-gray-900">{suggestion.title}</h5>
|
|
||||||
<p className="text-sm text-gray-600 mt-1">{suggestion.description}</p>
|
|
||||||
{suggestion.pass_criteria && (
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
<span className="font-medium">Erfolgskriterium:</span> {suggestion.pass_criteria}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{suggestion.is_automated && (
|
|
||||||
<span className="mt-1 inline-block px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded">
|
|
||||||
Automatisierbar {suggestion.automation_tool ? `(${suggestion.automation_tool})` : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => addSuggestedControl(suggestion)}
|
|
||||||
className="flex-shrink-0 flex items-center gap-1 px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
||||||
</svg>
|
|
||||||
Hinzufügen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!ragLoading && ragSuggestions.length === 0 && selectedRequirementId && (
|
|
||||||
<p className="text-sm text-purple-600 italic">
|
|
||||||
Klicken Sie auf "Vorschläge generieren", um KI-Controls abzurufen.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error Banner */}
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center justify-between">
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
@@ -839,7 +115,6 @@ export default function ControlsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Transition Error Banner (Anti-Fake-Evidence 409 violations) */}
|
|
||||||
{transitionError && (
|
{transitionError && (
|
||||||
<TransitionErrorBanner
|
<TransitionErrorBanner
|
||||||
controlId={transitionError.controlId}
|
controlId={transitionError.controlId}
|
||||||
@@ -848,7 +123,6 @@ export default function ControlsPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Requirements Alert */}
|
|
||||||
{state.requirements.length === 0 && !loading && (
|
{state.requirements.length === 0 && !loading && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
@@ -865,54 +139,17 @@ export default function ControlsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
<StatsCards
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
total={displayControls.length}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
implementedCount={implementedCount}
|
||||||
<div className="text-sm text-gray-500">Gesamt</div>
|
avgEffectiveness={avgEffectiveness}
|
||||||
<div className="text-3xl font-bold text-gray-900">{displayControls.length}</div>
|
partialCount={partialCount}
|
||||||
</div>
|
/>
|
||||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
|
||||||
<div className="text-sm text-green-600">Implementiert</div>
|
|
||||||
<div className="text-3xl font-bold text-green-600">{implementedCount}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-purple-200 p-6">
|
|
||||||
<div className="text-sm text-purple-600">Durchschn. Wirksamkeit</div>
|
|
||||||
<div className="text-3xl font-bold text-purple-600">{avgEffectiveness}%</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
|
||||||
<div className="text-sm text-yellow-600">Teilweise</div>
|
|
||||||
<div className="text-3xl font-bold text-yellow-600">{partialCount}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter */}
|
<FilterBar filter={filter} onFilterChange={setFilter} />
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-sm text-gray-500">Filter:</span>
|
|
||||||
{['all', 'implemented', 'partial', 'not-implemented', 'technical', 'organizational', 'preventive', 'detective'].map(f => (
|
|
||||||
<button
|
|
||||||
key={f}
|
|
||||||
onClick={() => setFilter(f)}
|
|
||||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
|
||||||
filter === f
|
|
||||||
? 'bg-purple-600 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{f === 'all' ? 'Alle' :
|
|
||||||
f === 'implemented' ? 'Implementiert' :
|
|
||||||
f === 'partial' ? 'Teilweise' :
|
|
||||||
f === 'not-implemented' ? 'Offen' :
|
|
||||||
f === 'technical' ? 'Technisch' :
|
|
||||||
f === 'organizational' ? 'Organisatorisch' :
|
|
||||||
f === 'preventive' ? 'Praeventiv' : 'Detektiv'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading State */}
|
|
||||||
{loading && <LoadingSkeleton />}
|
{loading && <LoadingSkeleton />}
|
||||||
|
|
||||||
{/* Controls List */}
|
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{filteredControls.map(control => (
|
{filteredControls.map(control => (
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface VerificationItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompleteModal({
|
||||||
|
item,
|
||||||
|
onSubmit,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
item: VerificationItem
|
||||||
|
onSubmit: (id: string, result: string, passed: boolean) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [result, setResult] = useState('')
|
||||||
|
const [passed, setPassed] = useState(true)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Verifikation abschliessen: {item.title}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ergebnis</label>
|
||||||
|
<textarea
|
||||||
|
value={result} onChange={(e) => setResult(e.target.value)}
|
||||||
|
rows={3} placeholder="Beschreiben Sie das Ergebnis der Verifikation..."
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Bewertung</label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setPassed(true)}
|
||||||
|
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||||
|
passed ? 'border-green-400 bg-green-50 text-green-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Bestanden
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPassed(false)}
|
||||||
|
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||||
|
!passed ? 'border-red-400 bg-red-50 text-red-700' : 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Nicht bestanden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onSubmit(item.id, result, passed)} disabled={!result}
|
||||||
|
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
result ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Abschliessen
|
||||||
|
</button>
|
||||||
|
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-700' },
|
||||||
|
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
|
||||||
|
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
|
||||||
|
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status }: { status: string }) {
|
||||||
|
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
interface SuggestedEvidence {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
method: string
|
||||||
|
tags?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERIFICATION_METHOD_LABELS: Record<string, string> = {
|
||||||
|
design_review: 'Design-Review',
|
||||||
|
calculation: 'Berechnung',
|
||||||
|
test_report: 'Pruefbericht',
|
||||||
|
validation: 'Validierung',
|
||||||
|
electrical_test: 'Elektrische Pruefung',
|
||||||
|
software_test: 'Software-Test',
|
||||||
|
penetration_test: 'Penetrationstest',
|
||||||
|
acceptance_protocol: 'Abnahmeprotokoll',
|
||||||
|
user_test: 'Anwendertest',
|
||||||
|
documentation_release: 'Dokumentenfreigabe',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SuggestEvidenceModal({
|
||||||
|
mitigations,
|
||||||
|
projectId,
|
||||||
|
onAddEvidence,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
mitigations: { id: string; title: string }[]
|
||||||
|
projectId: string
|
||||||
|
onAddEvidence: (title: string, description: string, method: string, mitigationId: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [selectedMitigation, setSelectedMitigation] = useState<string>('')
|
||||||
|
const [suggested, setSuggested] = useState<SuggestedEvidence[]>([])
|
||||||
|
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
||||||
|
|
||||||
|
async function handleSelectMitigation(mitigationId: string) {
|
||||||
|
setSelectedMitigation(mitigationId)
|
||||||
|
setSuggested([])
|
||||||
|
if (!mitigationId) return
|
||||||
|
setLoadingSuggestions(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${mitigationId}/suggest-evidence`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
if (res.ok) { const json = await res.json(); setSuggested(json.suggested_evidence || []) }
|
||||||
|
} catch (err) { console.error('Failed to suggest evidence:', err) }
|
||||||
|
finally { setLoadingSuggestions(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
|
||||||
|
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Nachweise vorschlagen</h3>
|
||||||
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mb-3">
|
||||||
|
Waehlen Sie eine Massnahme, um passende Nachweismethoden vorgeschlagen zu bekommen.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{mitigations.map(m => (
|
||||||
|
<button key={m.id} onClick={() => handleSelectMitigation(m.id)}
|
||||||
|
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||||
|
selectedMitigation === m.id
|
||||||
|
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
||||||
|
: 'border-gray-200 bg-white text-gray-700 hover:border-purple-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto p-6">
|
||||||
|
{loadingSuggestions ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
||||||
|
</div>
|
||||||
|
) : suggested.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{suggested.map(ev => (
|
||||||
|
<div key={ev.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-mono text-gray-400">{ev.id}</span>
|
||||||
|
{ev.method && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">
|
||||||
|
{VERIFICATION_METHOD_LABELS[ev.method] || ev.method}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{ev.name}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{ev.description}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onAddEvidence(ev.name, ev.description, ev.method || 'test_report', selectedMitigation)}
|
||||||
|
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
Uebernehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : selectedMitigation ? (
|
||||||
|
<div className="text-center py-12 text-gray-500">Keine Vorschlaege fuer diese Massnahme gefunden.</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12 text-gray-500">Waehlen Sie eine Massnahme aus, um Nachweise vorgeschlagen zu bekommen.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export interface VerificationFormData {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
method: string
|
||||||
|
linked_hazard_id: string
|
||||||
|
linked_mitigation_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERIFICATION_METHODS = [
|
||||||
|
{ value: 'design_review', label: 'Design-Review' },
|
||||||
|
{ value: 'calculation', label: 'Berechnung' },
|
||||||
|
{ value: 'test_report', label: 'Pruefbericht' },
|
||||||
|
{ value: 'validation', label: 'Validierung' },
|
||||||
|
{ value: 'electrical_test', label: 'Elektrische Pruefung' },
|
||||||
|
{ value: 'software_test', label: 'Software-Test' },
|
||||||
|
{ value: 'penetration_test', label: 'Penetrationstest' },
|
||||||
|
{ value: 'acceptance_protocol', label: 'Abnahmeprotokoll' },
|
||||||
|
{ value: 'user_test', label: 'Anwendertest' },
|
||||||
|
{ value: 'documentation_release', label: 'Dokumentenfreigabe' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function VerificationForm({
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
hazards,
|
||||||
|
mitigations,
|
||||||
|
}: {
|
||||||
|
onSubmit: (data: VerificationFormData) => void
|
||||||
|
onCancel: () => void
|
||||||
|
hazards: { id: string; name: string }[]
|
||||||
|
mitigations: { id: string; title: string }[]
|
||||||
|
}) {
|
||||||
|
const [formData, setFormData] = useState<VerificationFormData>({
|
||||||
|
title: '', description: '', method: 'test', linked_hazard_id: '', linked_mitigation_id: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neues Verifikationselement</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
||||||
|
<input
|
||||||
|
type="text" value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
placeholder="z.B. Funktionstest Lichtvorhang"
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Methode</label>
|
||||||
|
<select
|
||||||
|
value={formData.method}
|
||||||
|
onChange={(e) => setFormData({ ...formData, method: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
>
|
||||||
|
{VERIFICATION_METHODS.map((m) => (
|
||||||
|
<option key={m.value} value={m.value}>{m.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
rows={2} placeholder="Beschreiben Sie den Verifikationsschritt..."
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Gefaehrdung</label>
|
||||||
|
<select
|
||||||
|
value={formData.linked_hazard_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, linked_hazard_id: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">-- Keine --</option>
|
||||||
|
{hazards.map((h) => <option key={h.id} value={h.id}>{h.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Massnahme</label>
|
||||||
|
<select
|
||||||
|
value={formData.linked_mitigation_id}
|
||||||
|
onChange={(e) => setFormData({ ...formData, linked_mitigation_id: e.target.value })}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
||||||
|
>
|
||||||
|
<option value="">-- Keine --</option>
|
||||||
|
{mitigations.map((m) => <option key={m.id} value={m.id}>{m.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => onSubmit(formData)} disabled={!formData.title}
|
||||||
|
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
||||||
|
formData.title ? 'bg-purple-600 text-white hover:bg-purple-700' : 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Hinzufuegen
|
||||||
|
</button>
|
||||||
|
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { StatusBadge } from './StatusBadge'
|
||||||
|
|
||||||
|
interface VerificationItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
method: string
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'failed'
|
||||||
|
result: string | null
|
||||||
|
linked_hazard_name: string | null
|
||||||
|
linked_mitigation_name: string | null
|
||||||
|
completed_at: string | null
|
||||||
|
completed_by: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERIFICATION_METHOD_LABELS: Record<string, string> = {
|
||||||
|
design_review: 'Design-Review', calculation: 'Berechnung', test_report: 'Pruefbericht',
|
||||||
|
validation: 'Validierung', electrical_test: 'Elektrische Pruefung', software_test: 'Software-Test',
|
||||||
|
penetration_test: 'Penetrationstest', acceptance_protocol: 'Abnahmeprotokoll',
|
||||||
|
user_test: 'Anwendertest', documentation_release: 'Dokumentenfreigabe',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerificationTable({
|
||||||
|
items,
|
||||||
|
onComplete,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
items: VerificationItem[]
|
||||||
|
onComplete: (item: VerificationItem) => void
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Methode</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Gefaehrdung</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Massnahme</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ergebnis</th>
|
||||||
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
|
||||||
|
{item.description && (
|
||||||
|
<div className="text-xs text-gray-500 truncate max-w-[200px]">{item.description}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
{VERIFICATION_METHOD_LABELS[item.method] || item.method}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_hazard_name || '--'}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_mitigation_name || '--'}</td>
|
||||||
|
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 max-w-[150px] truncate">{item.result || '--'}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
{item.status !== 'completed' && item.status !== 'failed' && (
|
||||||
|
<button
|
||||||
|
onClick={() => onComplete(item)}
|
||||||
|
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||||
|
>
|
||||||
|
Abschliessen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(item.id)}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
|
import { VerificationForm } from './_components/VerificationForm'
|
||||||
|
import { CompleteModal } from './_components/CompleteModal'
|
||||||
|
import { SuggestEvidenceModal } from './_components/SuggestEvidenceModal'
|
||||||
|
import { VerificationTable } from './_components/VerificationTable'
|
||||||
|
import type { VerificationFormData } from './_components/VerificationForm'
|
||||||
|
|
||||||
interface VerificationItem {
|
interface VerificationItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -19,360 +24,6 @@ interface VerificationItem {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SuggestedEvidence {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
method: string
|
|
||||||
tags?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const VERIFICATION_METHODS = [
|
|
||||||
{ value: 'design_review', label: 'Design-Review', description: 'Systematische Pruefung der Konstruktionsunterlagen' },
|
|
||||||
{ value: 'calculation', label: 'Berechnung', description: 'Rechnerischer Nachweis (FEM, Festigkeit, Thermik)' },
|
|
||||||
{ value: 'test_report', label: 'Pruefbericht', description: 'Dokumentierter Test mit Messprotokoll' },
|
|
||||||
{ value: 'validation', label: 'Validierung', description: 'Nachweis der Eignung unter realen Betriebsbedingungen' },
|
|
||||||
{ value: 'electrical_test', label: 'Elektrische Pruefung', description: 'Isolationsmessung, Schutzleiter, Spannungsfestigkeit' },
|
|
||||||
{ value: 'software_test', label: 'Software-Test', description: 'Unit-, Integrations- oder Systemtest der Steuerungssoftware' },
|
|
||||||
{ value: 'penetration_test', label: 'Penetrationstest', description: 'Security-Test der Netzwerk- und Steuerungskomponenten' },
|
|
||||||
{ value: 'acceptance_protocol', label: 'Abnahmeprotokoll', description: 'Formelle Abnahme mit Checkliste und Unterschrift' },
|
|
||||||
{ value: 'user_test', label: 'Anwendertest', description: 'Pruefung durch Bediener unter realen Einsatzbedingungen' },
|
|
||||||
{ value: 'documentation_release', label: 'Dokumentenfreigabe', description: 'Formelle Freigabe der technischen Dokumentation' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
|
||||||
pending: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-700' },
|
|
||||||
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
|
|
||||||
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
|
|
||||||
failed: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-700' },
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
|
||||||
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
|
|
||||||
{config.label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VerificationFormData {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
method: string
|
|
||||||
linked_hazard_id: string
|
|
||||||
linked_mitigation_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function VerificationForm({
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
hazards,
|
|
||||||
mitigations,
|
|
||||||
}: {
|
|
||||||
onSubmit: (data: VerificationFormData) => void
|
|
||||||
onCancel: () => void
|
|
||||||
hazards: { id: string; name: string }[]
|
|
||||||
mitigations: { id: string; title: string }[]
|
|
||||||
}) {
|
|
||||||
const [formData, setFormData] = useState<VerificationFormData>({
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
method: 'test',
|
|
||||||
linked_hazard_id: '',
|
|
||||||
linked_mitigation_id: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Neues Verifikationselement</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
||||||
placeholder="z.B. Funktionstest Lichtvorhang"
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Methode</label>
|
|
||||||
<select
|
|
||||||
value={formData.method}
|
|
||||||
onChange={(e) => setFormData({ ...formData, method: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
||||||
>
|
|
||||||
{VERIFICATION_METHODS.map((m) => (
|
|
||||||
<option key={m.value} value={m.value}>{m.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
||||||
rows={2}
|
|
||||||
placeholder="Beschreiben Sie den Verifikationsschritt..."
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Gefaehrdung</label>
|
|
||||||
<select
|
|
||||||
value={formData.linked_hazard_id}
|
|
||||||
onChange={(e) => setFormData({ ...formData, linked_hazard_id: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="">-- Keine --</option>
|
|
||||||
{hazards.map((h) => (
|
|
||||||
<option key={h.id} value={h.id}>{h.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Verknuepfte Massnahme</label>
|
|
||||||
<select
|
|
||||||
value={formData.linked_mitigation_id}
|
|
||||||
onChange={(e) => setFormData({ ...formData, linked_mitigation_id: e.target.value })}
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
||||||
>
|
|
||||||
<option value="">-- Keine --</option>
|
|
||||||
{mitigations.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>{m.title}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => onSubmit(formData)}
|
|
||||||
disabled={!formData.title}
|
|
||||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
|
||||||
formData.title
|
|
||||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
|
||||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Hinzufuegen
|
|
||||||
</button>
|
|
||||||
<button onClick={onCancel} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompleteModal({
|
|
||||||
item,
|
|
||||||
onSubmit,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
item: VerificationItem
|
|
||||||
onSubmit: (id: string, result: string, passed: boolean) => void
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const [result, setResult] = useState('')
|
|
||||||
const [passed, setPassed] = useState(true)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-lg p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
|
||||||
Verifikation abschliessen: {item.title}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ergebnis</label>
|
|
||||||
<textarea
|
|
||||||
value={result}
|
|
||||||
onChange={(e) => setResult(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
placeholder="Beschreiben Sie das Ergebnis der Verifikation..."
|
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent dark:bg-gray-700 dark:border-gray-600 dark:text-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Bewertung</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => setPassed(true)}
|
|
||||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
|
||||||
passed
|
|
||||||
? 'border-green-400 bg-green-50 text-green-700'
|
|
||||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Bestanden
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setPassed(false)}
|
|
||||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
|
||||||
!passed
|
|
||||||
? 'border-red-400 bg-red-50 text-red-700'
|
|
||||||
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Nicht bestanden
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={() => onSubmit(item.id, result, passed)}
|
|
||||||
disabled={!result}
|
|
||||||
className={`px-6 py-2 rounded-lg font-medium transition-colors ${
|
|
||||||
result
|
|
||||||
? 'bg-purple-600 text-white hover:bg-purple-700'
|
|
||||||
: 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Abschliessen
|
|
||||||
</button>
|
|
||||||
<button onClick={onClose} className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Suggest Evidence Modal (Phase 5)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function SuggestEvidenceModal({
|
|
||||||
mitigations,
|
|
||||||
projectId,
|
|
||||||
onAddEvidence,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
mitigations: { id: string; title: string }[]
|
|
||||||
projectId: string
|
|
||||||
onAddEvidence: (title: string, description: string, method: string, mitigationId: string) => void
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const [selectedMitigation, setSelectedMitigation] = useState<string>('')
|
|
||||||
const [suggested, setSuggested] = useState<SuggestedEvidence[]>([])
|
|
||||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false)
|
|
||||||
|
|
||||||
async function handleSelectMitigation(mitigationId: string) {
|
|
||||||
setSelectedMitigation(mitigationId)
|
|
||||||
setSuggested([])
|
|
||||||
if (!mitigationId) return
|
|
||||||
|
|
||||||
setLoadingSuggestions(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations/${mitigationId}/suggest-evidence`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
})
|
|
||||||
if (res.ok) {
|
|
||||||
const json = await res.json()
|
|
||||||
setSuggested(json.suggested_evidence || [])
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to suggest evidence:', err)
|
|
||||||
} finally {
|
|
||||||
setLoadingSuggestions(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl w-full max-w-3xl max-h-[85vh] flex flex-col">
|
|
||||||
<div className="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Nachweise vorschlagen</h3>
|
|
||||||
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-500 mb-3">
|
|
||||||
Waehlen Sie eine Massnahme, um passende Nachweismethoden vorgeschlagen zu bekommen.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{mitigations.map(m => (
|
|
||||||
<button
|
|
||||||
key={m.id}
|
|
||||||
onClick={() => handleSelectMitigation(m.id)}
|
|
||||||
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
|
||||||
selectedMitigation === m.id
|
|
||||||
? 'border-purple-400 bg-purple-50 text-purple-700 font-medium'
|
|
||||||
: 'border-gray-200 bg-white text-gray-700 hover:border-purple-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{m.title}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-6">
|
|
||||||
{loadingSuggestions ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600" />
|
|
||||||
</div>
|
|
||||||
) : suggested.length > 0 ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{suggested.map(ev => (
|
|
||||||
<div key={ev.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-xs font-mono text-gray-400">{ev.id}</span>
|
|
||||||
{ev.method && (
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-50 text-blue-600">
|
|
||||||
{VERIFICATION_METHODS.find(m => m.value === ev.method)?.label || ev.method}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{ev.name}</div>
|
|
||||||
<div className="text-xs text-gray-500 mt-0.5">{ev.description}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => onAddEvidence(ev.name, ev.description, ev.method || 'test_report', selectedMitigation)}
|
|
||||||
className="ml-3 px-3 py-1.5 text-xs bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex-shrink-0"
|
|
||||||
>
|
|
||||||
Uebernehmen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : selectedMitigation ? (
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
Keine Vorschlaege fuer diese Massnahme gefunden.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-12 text-gray-500">
|
|
||||||
Waehlen Sie eine Massnahme aus, um Nachweise vorgeschlagen zu bekommen.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Main Page
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export default function VerificationPage() {
|
export default function VerificationPage() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const projectId = params.projectId as string
|
const projectId = params.projectId as string
|
||||||
@@ -382,12 +33,9 @@ export default function VerificationPage() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showForm, setShowForm] = useState(false)
|
const [showForm, setShowForm] = useState(false)
|
||||||
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
|
const [completingItem, setCompletingItem] = useState<VerificationItem | null>(null)
|
||||||
// Phase 5: Suggest evidence
|
|
||||||
const [showSuggest, setShowSuggest] = useState(false)
|
const [showSuggest, setShowSuggest] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { fetchData() }, [projectId])
|
||||||
fetchData()
|
|
||||||
}, [projectId])
|
|
||||||
|
|
||||||
async function fetchData() {
|
async function fetchData() {
|
||||||
try {
|
try {
|
||||||
@@ -396,87 +44,47 @@ export default function VerificationPage() {
|
|||||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/hazards`),
|
||||||
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
fetch(`/api/sdk/v1/iace/projects/${projectId}/mitigations`),
|
||||||
])
|
])
|
||||||
if (verRes.ok) {
|
if (verRes.ok) { const json = await verRes.json(); setItems(json.verifications || json || []) }
|
||||||
const json = await verRes.json()
|
if (hazRes.ok) { const json = await hazRes.json(); setHazards((json.hazards || json || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name }))) }
|
||||||
setItems(json.verifications || json || [])
|
if (mitRes.ok) { const json = await mitRes.json(); setMitigations((json.mitigations || json || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title }))) }
|
||||||
}
|
} catch (err) { console.error('Failed to fetch data:', err) }
|
||||||
if (hazRes.ok) {
|
finally { setLoading(false) }
|
||||||
const json = await hazRes.json()
|
|
||||||
setHazards((json.hazards || json || []).map((h: { id: string; name: string }) => ({ id: h.id, name: h.name })))
|
|
||||||
}
|
|
||||||
if (mitRes.ok) {
|
|
||||||
const json = await mitRes.json()
|
|
||||||
setMitigations((json.mitigations || json || []).map((m: { id: string; title: string }) => ({ id: m.id, title: m.title })))
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch data:', err)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(data: VerificationFormData) {
|
async function handleSubmit(data: VerificationFormData) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||||
method: 'POST',
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) { setShowForm(false); await fetchData() }
|
||||||
setShowForm(false)
|
} catch (err) { console.error('Failed to add verification:', err) }
|
||||||
await fetchData()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to add verification:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
|
async function handleAddSuggestedEvidence(title: string, description: string, method: string, mitigationId: string) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications`, {
|
||||||
method: 'POST',
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: JSON.stringify({ title, description, method, linked_mitigation_id: mitigationId }),
|
||||||
body: JSON.stringify({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
method,
|
|
||||||
linked_mitigation_id: mitigationId,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) await fetchData()
|
||||||
await fetchData()
|
} catch (err) { console.error('Failed to add suggested evidence:', err) }
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to add suggested evidence:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleComplete(id: string, result: string, passed: boolean) {
|
async function handleComplete(id: string, result: string, passed: boolean) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}/complete`, {
|
||||||
method: 'POST',
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ result, passed }),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ result, passed }),
|
|
||||||
})
|
})
|
||||||
if (res.ok) {
|
if (res.ok) { setCompletingItem(null); await fetchData() }
|
||||||
setCompletingItem(null)
|
} catch (err) { console.error('Failed to complete verification:', err) }
|
||||||
await fetchData()
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to complete verification:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
if (!confirm('Verifikation wirklich loeschen?')) return
|
if (!confirm('Verifikation wirklich loeschen?')) return
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}`, { method: 'DELETE' })
|
const res = await fetch(`/api/sdk/v1/iace/projects/${projectId}/verifications/${id}`, { method: 'DELETE' })
|
||||||
if (res.ok) {
|
if (res.ok) await fetchData()
|
||||||
await fetchData()
|
} catch (err) { console.error('Failed to delete verification:', err) }
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete verification:', err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const completed = items.filter((i) => i.status === 'completed').length
|
const completed = items.filter((i) => i.status === 'completed').length
|
||||||
@@ -493,7 +101,6 @@ export default function VerificationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikationsplan</h1>
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Verifikationsplan</h1>
|
||||||
@@ -503,8 +110,7 @@ export default function VerificationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{mitigations.length > 0 && (
|
{mitigations.length > 0 && (
|
||||||
<button
|
<button onClick={() => setShowSuggest(true)}
|
||||||
onClick={() => setShowSuggest(true)}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm"
|
className="flex items-center gap-2 px-3 py-2 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors text-sm"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -513,8 +119,7 @@ export default function VerificationPage() {
|
|||||||
Nachweise vorschlagen
|
Nachweise vorschlagen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button onClick={() => setShowForm(true)}
|
||||||
onClick={() => setShowForm(true)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -525,7 +130,6 @@ export default function VerificationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
{items.length > 0 && (
|
{items.length > 0 && (
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 text-center">
|
||||||
@@ -547,95 +151,20 @@ export default function VerificationPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<VerificationForm
|
<VerificationForm onSubmit={handleSubmit} onCancel={() => setShowForm(false)} hazards={hazards} mitigations={mitigations} />
|
||||||
onSubmit={handleSubmit}
|
|
||||||
onCancel={() => setShowForm(false)}
|
|
||||||
hazards={hazards}
|
|
||||||
mitigations={mitigations}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Complete Modal */}
|
|
||||||
{completingItem && (
|
{completingItem && (
|
||||||
<CompleteModal
|
<CompleteModal item={completingItem} onSubmit={handleComplete} onClose={() => setCompletingItem(null)} />
|
||||||
item={completingItem}
|
|
||||||
onSubmit={handleComplete}
|
|
||||||
onClose={() => setCompletingItem(null)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Suggest Evidence Modal (Phase 5) */}
|
|
||||||
{showSuggest && (
|
{showSuggest && (
|
||||||
<SuggestEvidenceModal
|
<SuggestEvidenceModal mitigations={mitigations} projectId={projectId} onAddEvidence={handleAddSuggestedEvidence} onClose={() => setShowSuggest(false)} />
|
||||||
mitigations={mitigations}
|
|
||||||
projectId={projectId}
|
|
||||||
onAddEvidence={handleAddSuggestedEvidence}
|
|
||||||
onClose={() => setShowSuggest(false)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
{items.length > 0 ? (
|
{items.length > 0 ? (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
<VerificationTable items={items} onComplete={setCompletingItem} onDelete={handleDelete} />
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-50 dark:bg-gray-750 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Titel</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Methode</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Gefaehrdung</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Massnahme</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Ergebnis</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
||||||
{items.map((item) => (
|
|
||||||
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{item.title}</div>
|
|
||||||
{item.description && (
|
|
||||||
<div className="text-xs text-gray-500 truncate max-w-[200px]">{item.description}</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
|
||||||
{VERIFICATION_METHODS.find((m) => m.value === item.method)?.label || item.method}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_hazard_name || '--'}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600">{item.linked_mitigation_name || '--'}</td>
|
|
||||||
<td className="px-4 py-3"><StatusBadge status={item.status} /></td>
|
|
||||||
<td className="px-4 py-3 text-sm text-gray-600 max-w-[150px] truncate">{item.result || '--'}</td>
|
|
||||||
<td className="px-4 py-3 text-right">
|
|
||||||
<div className="flex items-center justify-end gap-1">
|
|
||||||
{item.status !== 'completed' && item.status !== 'failed' && (
|
|
||||||
<button
|
|
||||||
onClick={() => setCompletingItem(item)}
|
|
||||||
className="text-xs px-2.5 py-1 bg-green-50 text-green-700 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
|
||||||
>
|
|
||||||
Abschliessen
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(item.id)}
|
|
||||||
className="p-1 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
|
||||||
>
|
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
!showForm && (
|
!showForm && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||||
@@ -651,17 +180,11 @@ export default function VerificationPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex items-center justify-center gap-3">
|
<div className="mt-6 flex items-center justify-center gap-3">
|
||||||
{mitigations.length > 0 && (
|
{mitigations.length > 0 && (
|
||||||
<button
|
<button onClick={() => setShowSuggest(true)} className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors">
|
||||||
onClick={() => setShowSuggest(true)}
|
|
||||||
className="px-6 py-3 border border-green-300 text-green-700 rounded-lg hover:bg-green-50 transition-colors"
|
|
||||||
>
|
|
||||||
Nachweise vorschlagen
|
Nachweise vorschlagen
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button onClick={() => setShowForm(true)} className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors">
|
||||||
onClick={() => setShowForm(true)}
|
|
||||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
|
||||||
>
|
|
||||||
Erste Verifikation anlegen
|
Erste Verifikation anlegen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
392
admin-compliance/app/sdk/training/_components/ContentTab.tsx
Normal file
392
admin-compliance/app/sdk/training/_components/ContentTab.tsx
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import AudioPlayer from '@/components/training/AudioPlayer'
|
||||||
|
import VideoPlayer from '@/components/training/VideoPlayer'
|
||||||
|
import ScriptPreview from '@/components/training/ScriptPreview'
|
||||||
|
import type {
|
||||||
|
TrainingModule, ModuleContent, TrainingMedia,
|
||||||
|
TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult,
|
||||||
|
} from '@/lib/sdk/training/types'
|
||||||
|
import { TARGET_AUDIENCE_LABELS, ROLE_LABELS, REGULATION_LABELS } from '@/lib/sdk/training/types'
|
||||||
|
|
||||||
|
export function ContentTab({
|
||||||
|
modules,
|
||||||
|
blocks,
|
||||||
|
canonicalMeta,
|
||||||
|
selectedModuleId,
|
||||||
|
onSelectedModuleIdChange,
|
||||||
|
generatedContent,
|
||||||
|
generating,
|
||||||
|
bulkGenerating,
|
||||||
|
bulkResult,
|
||||||
|
moduleMedia,
|
||||||
|
interactiveGenerating,
|
||||||
|
blockPreview,
|
||||||
|
blockPreviewId,
|
||||||
|
blockGenerating,
|
||||||
|
blockResult,
|
||||||
|
showBlockCreate,
|
||||||
|
onShowBlockCreate,
|
||||||
|
onGenerateContent,
|
||||||
|
onGenerateQuiz,
|
||||||
|
onGenerateInteractiveVideo,
|
||||||
|
onPublishContent,
|
||||||
|
onBulkContent,
|
||||||
|
onBulkQuiz,
|
||||||
|
onPreviewBlock,
|
||||||
|
onGenerateBlock,
|
||||||
|
onDeleteBlock,
|
||||||
|
onCreateBlock,
|
||||||
|
}: {
|
||||||
|
modules: TrainingModule[]
|
||||||
|
blocks: TrainingBlockConfig[]
|
||||||
|
canonicalMeta: CanonicalControlMeta | null
|
||||||
|
selectedModuleId: string
|
||||||
|
onSelectedModuleIdChange: (id: string) => void
|
||||||
|
generatedContent: ModuleContent | null
|
||||||
|
generating: boolean
|
||||||
|
bulkGenerating: boolean
|
||||||
|
bulkResult: { generated: number; skipped: number; errors: string[] } | null
|
||||||
|
moduleMedia: TrainingMedia[]
|
||||||
|
interactiveGenerating: boolean
|
||||||
|
blockPreview: BlockPreview | null
|
||||||
|
blockPreviewId: string
|
||||||
|
blockGenerating: boolean
|
||||||
|
blockResult: BlockGenerateResult | null
|
||||||
|
showBlockCreate: boolean
|
||||||
|
onShowBlockCreate: (show: boolean) => void
|
||||||
|
onGenerateContent: () => void
|
||||||
|
onGenerateQuiz: () => void
|
||||||
|
onGenerateInteractiveVideo: () => void
|
||||||
|
onPublishContent: (id: string) => void
|
||||||
|
onBulkContent: () => void
|
||||||
|
onBulkQuiz: () => void
|
||||||
|
onPreviewBlock: (id: string) => void
|
||||||
|
onGenerateBlock: (id: string) => void
|
||||||
|
onDeleteBlock: (id: string) => void
|
||||||
|
onCreateBlock: (data: {
|
||||||
|
name: string; description?: string; domain_filter?: string; category_filter?: string;
|
||||||
|
severity_filter?: string; target_audience_filter?: string; regulation_area: string;
|
||||||
|
module_code_prefix: string; max_controls_per_module?: number;
|
||||||
|
}) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Training Blocks */}
|
||||||
|
<div className="bg-white border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700">Schulungsbloecke aus Controls</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Canonical Controls nach Kriterien filtern und automatisch Schulungsmodule generieren
|
||||||
|
{canonicalMeta && <span className="ml-2 text-gray-400">({canonicalMeta.total} Controls verfuegbar)</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => onShowBlockCreate(true)}
|
||||||
|
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
+ Neuen Block erstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{blocks.length > 0 ? (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-600">Name</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-600">Domain</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-600">Zielgruppe</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-600">Severity</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-600">Prefix</th>
|
||||||
|
<th className="px-3 py-2 text-left font-medium text-gray-600">Letzte Generierung</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium text-gray-600">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{blocks.map(block => (
|
||||||
|
<tr key={block.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="font-medium text-gray-900">{block.name}</div>
|
||||||
|
{block.description && <div className="text-xs text-gray-500">{block.description}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-600">{block.domain_filter || 'Alle'}</td>
|
||||||
|
<td className="px-3 py-2 text-gray-600">
|
||||||
|
{block.target_audience_filter ? (TARGET_AUDIENCE_LABELS[block.target_audience_filter] || block.target_audience_filter) : 'Alle'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-gray-600">{block.severity_filter || 'Alle'}</td>
|
||||||
|
<td className="px-3 py-2"><code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{block.module_code_prefix}</code></td>
|
||||||
|
<td className="px-3 py-2 text-gray-500 text-xs">
|
||||||
|
{block.last_generated_at ? new Date(block.last_generated_at).toLocaleString('de-DE') : 'Noch nie'}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
<button onClick={() => onPreviewBlock(block.id)} className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Preview</button>
|
||||||
|
<button onClick={() => onGenerateBlock(block.id)} disabled={blockGenerating} className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50">
|
||||||
|
{blockGenerating ? 'Generiert...' : 'Generieren'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => onDeleteBlock(block.id)} className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200">Loeschen</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500 text-sm">
|
||||||
|
Noch keine Schulungsbloecke konfiguriert. Erstelle einen Block, um Controls automatisch in Module umzuwandeln.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blockPreview && blockPreviewId && (
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-blue-800 mb-2">Preview: {blocks.find(b => b.id === blockPreviewId)?.name}</h4>
|
||||||
|
<div className="flex gap-6 text-sm mb-3">
|
||||||
|
<span className="text-blue-700">Controls: <strong>{blockPreview.control_count}</strong></span>
|
||||||
|
<span className="text-blue-700">Module: <strong>{blockPreview.module_count}</strong></span>
|
||||||
|
<span className="text-blue-700">Rollen: <strong>{blockPreview.proposed_roles.map(r => ROLE_LABELS[r] || r).join(', ')}</strong></span>
|
||||||
|
</div>
|
||||||
|
{blockPreview.controls.length > 0 && (
|
||||||
|
<details className="text-xs">
|
||||||
|
<summary className="cursor-pointer text-blue-600 hover:text-blue-800">Passende Controls anzeigen ({blockPreview.control_count})</summary>
|
||||||
|
<div className="mt-2 max-h-48 overflow-y-auto">
|
||||||
|
{blockPreview.controls.slice(0, 50).map(ctrl => (
|
||||||
|
<div key={ctrl.control_id} className="flex gap-2 py-1 border-b border-blue-100">
|
||||||
|
<code className="text-xs bg-blue-100 px-1 rounded shrink-0">{ctrl.control_id}</code>
|
||||||
|
<span className="text-gray-700 truncate">{ctrl.title}</span>
|
||||||
|
<span className={`text-xs px-1.5 rounded shrink-0 ${ctrl.severity === 'critical' ? 'bg-red-100 text-red-700' : ctrl.severity === 'high' ? 'bg-orange-100 text-orange-700' : 'bg-gray-100 text-gray-600'}`}>{ctrl.severity}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{blockPreview.control_count > 50 && <div className="text-gray-500 py-1">... und {blockPreview.control_count - 50} weitere</div>}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blockResult && (
|
||||||
|
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||||
|
<h4 className="text-sm font-medium text-green-800 mb-2">Generierung abgeschlossen</h4>
|
||||||
|
<div className="flex gap-6 text-sm">
|
||||||
|
<span className="text-green-700">Module erstellt: <strong>{blockResult.modules_created}</strong></span>
|
||||||
|
<span className="text-green-700">Controls verknuepft: <strong>{blockResult.controls_linked}</strong></span>
|
||||||
|
<span className="text-green-700">Matrix-Eintraege: <strong>{blockResult.matrix_entries_created}</strong></span>
|
||||||
|
<span className="text-green-700">Content generiert: <strong>{blockResult.content_generated}</strong></span>
|
||||||
|
</div>
|
||||||
|
{blockResult.errors && blockResult.errors.length > 0 && (
|
||||||
|
<div className="mt-2 text-xs text-red-600">{blockResult.errors.map((err, i) => <div key={i}>{err}</div>)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Block Create Modal */}
|
||||||
|
{showBlockCreate && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Neuen Schulungsblock erstellen</h3>
|
||||||
|
<form onSubmit={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
onCreateBlock({
|
||||||
|
name: fd.get('name') as string,
|
||||||
|
description: fd.get('description') as string || undefined,
|
||||||
|
domain_filter: fd.get('domain_filter') as string || undefined,
|
||||||
|
category_filter: fd.get('category_filter') as string || undefined,
|
||||||
|
severity_filter: fd.get('severity_filter') as string || undefined,
|
||||||
|
target_audience_filter: fd.get('target_audience_filter') as string || undefined,
|
||||||
|
regulation_area: fd.get('regulation_area') as string,
|
||||||
|
module_code_prefix: fd.get('module_code_prefix') as string,
|
||||||
|
max_controls_per_module: parseInt(fd.get('max_controls_per_module') as string) || 20,
|
||||||
|
})
|
||||||
|
}} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-600 block mb-1">Name *</label>
|
||||||
|
<input name="name" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. Authentifizierung fuer Geschaeftsfuehrung" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-600 block mb-1">Beschreibung</label>
|
||||||
|
<textarea name="description" className="w-full px-3 py-2 text-sm border rounded-lg" rows={2} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-600 block mb-1">Domain-Filter</label>
|
||||||
|
<select name="domain_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||||
|
<option value="">Alle Domains</option>
|
||||||
|
{canonicalMeta?.domains.map(d => <option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-600 block mb-1">Kategorie-Filter</label>
|
||||||
|
<select name="category_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||||
|
<option value="">Alle Kategorien</option>
|
||||||
|
{canonicalMeta?.categories.filter(c => c.category !== 'uncategorized').map(c => (
|
||||||
|
<option key={c.category} value={c.category}>{c.category} ({c.count})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-600 block mb-1">Zielgruppe</label>
|
||||||
|
<select name="target_audience_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||||
|
<option value="">Alle Zielgruppen</option>
|
||||||
|
{canonicalMeta?.audiences.filter(a => a.audience !== 'unset').map(a => (
|
||||||
|
<option key={a.audience} value={a.audience}>{TARGET_AUDIENCE_LABELS[a.audience] || a.audience} ({a.count})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-600 block mb-1">Severity</label>
|
||||||
|
<select name="severity_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||||
|
<option value="">Alle</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-600 block mb-1">Regulierungsbereich *</label>
|
||||||
|
<select name="regulation_area" required className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||||
|
{Object.entries(REGULATION_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-600 block mb-1">Modul-Code-Prefix *</label>
|
||||||
|
<input name="module_code_prefix" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. CB-AUTH" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-gray-600 block mb-1">Max. Controls pro Modul</label>
|
||||||
|
<input name="max_controls_per_module" type="number" defaultValue={20} min={1} max={50} className="w-full px-3 py-2 text-sm border rounded-lg" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button type="submit" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">Erstellen</button>
|
||||||
|
<button type="button" onClick={() => onShowBlockCreate(false)} className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bulk Generation */}
|
||||||
|
<div className="bg-white border rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Bulk-Generierung</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">Generiere Inhalte und Quiz-Fragen fuer alle Module auf einmal</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={onBulkContent} disabled={bulkGenerating} className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||||
|
{bulkGenerating ? 'Generiere...' : 'Alle Inhalte generieren'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onBulkQuiz} disabled={bulkGenerating} className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||||
|
{bulkGenerating ? 'Generiere...' : 'Alle Quizfragen generieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{bulkResult && (
|
||||||
|
<div className="mt-4 p-3 bg-gray-50 rounded-lg text-sm">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
<span className="text-green-700">Generiert: {bulkResult.generated}</span>
|
||||||
|
<span className="text-gray-500">Uebersprungen: {bulkResult.skipped}</span>
|
||||||
|
{bulkResult.errors?.length > 0 && <span className="text-red-600">Fehler: {bulkResult.errors.length}</span>}
|
||||||
|
</div>
|
||||||
|
{bulkResult.errors?.length > 0 && (
|
||||||
|
<div className="mt-2 text-xs text-red-600">{bulkResult.errors.map((err, i) => <div key={i}>{err}</div>)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LLM Content Generator */}
|
||||||
|
<div className="bg-white border rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 mb-3">LLM-Content-Generator</h3>
|
||||||
|
<p className="text-xs text-gray-500 mb-4">Generiere Schulungsinhalte und Quiz-Fragen automatisch via KI</p>
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-xs text-gray-600 block mb-1">Modul auswaehlen</label>
|
||||||
|
<select
|
||||||
|
value={selectedModuleId}
|
||||||
|
onChange={e => onSelectedModuleIdChange(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 text-sm border rounded-lg bg-white"
|
||||||
|
>
|
||||||
|
<option value="">Modul waehlen...</option>
|
||||||
|
{modules.map(m => <option key={m.id} value={m.id}>{m.module_code} - {m.title}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onClick={onGenerateContent} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{generating ? 'Generiere...' : 'Inhalt generieren'}
|
||||||
|
</button>
|
||||||
|
<button onClick={onGenerateQuiz} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
{generating ? 'Generiere...' : 'Quiz generieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{generatedContent && (
|
||||||
|
<div className="bg-white border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700">Generierter Inhalt (v{generatedContent.version})</h3>
|
||||||
|
<p className="text-xs text-gray-500">Generiert von: {generatedContent.generated_by} ({generatedContent.llm_model})</p>
|
||||||
|
</div>
|
||||||
|
{!generatedContent.is_published ? (
|
||||||
|
<button onClick={() => onPublishContent(generatedContent.id)} className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700">Veroeffentlichen</button>
|
||||||
|
) : (
|
||||||
|
<span className="px-3 py-1.5 text-xs bg-green-100 text-green-700 rounded">Veroeffentlicht</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="prose prose-sm max-w-none border rounded p-4 bg-gray-50 max-h-96 overflow-y-auto">
|
||||||
|
<pre className="whitespace-pre-wrap text-sm text-gray-800">{generatedContent.content_body}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedModuleId && generatedContent?.is_published && (
|
||||||
|
<AudioPlayer
|
||||||
|
moduleId={selectedModuleId}
|
||||||
|
audio={moduleMedia.find(m => m.media_type === 'audio') || null}
|
||||||
|
onMediaUpdate={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedModuleId && generatedContent?.is_published && (
|
||||||
|
<VideoPlayer
|
||||||
|
moduleId={selectedModuleId}
|
||||||
|
video={moduleMedia.find(m => m.media_type === 'video') || null}
|
||||||
|
onMediaUpdate={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedModuleId && generatedContent?.is_published && (
|
||||||
|
<div className="bg-white border rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700">Interaktives Video</h3>
|
||||||
|
<p className="text-xs text-gray-500">Video mit Narrator-Persona und Checkpoint-Quizzes</p>
|
||||||
|
</div>
|
||||||
|
{moduleMedia.some(m => m.media_type === 'interactive_video' && m.status === 'completed') ? (
|
||||||
|
<span className="px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv erstellt</span>
|
||||||
|
) : (
|
||||||
|
<button onClick={onGenerateInteractiveVideo} disabled={interactiveGenerating} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||||
|
{interactiveGenerating ? 'Generiere interaktives Video...' : 'Interaktives Video generieren'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{moduleMedia.filter(m => m.media_type === 'interactive_video' && m.status === 'completed').map(m => (
|
||||||
|
<div key={m.id} className="text-xs text-gray-500 space-y-1 bg-gray-50 rounded p-3">
|
||||||
|
<p>Dauer: {Math.round(m.duration_seconds / 60)} Min | Groesse: {(m.file_size_bytes / 1024 / 1024).toFixed(1)} MB</p>
|
||||||
|
<p>Generiert: {new Date(m.created_at).toLocaleString('de-DE')}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedModuleId && generatedContent?.is_published && (
|
||||||
|
<ScriptPreview moduleId={selectedModuleId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user