All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard). SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest. Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
288 lines
9.3 KiB
TypeScript
288 lines
9.3 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useMemo } from 'react'
|
|
import {
|
|
useVendorCompliance,
|
|
CONTROLS_LIBRARY,
|
|
getControlDomainMeta,
|
|
getControlsGroupedByDomain,
|
|
ControlDomain,
|
|
ControlStatus,
|
|
} from '@/lib/sdk/vendor-compliance'
|
|
|
|
export default function ControlsPage() {
|
|
const { controlInstances, vendors, isLoading } = useVendorCompliance()
|
|
|
|
const [selectedDomain, setSelectedDomain] = useState<ControlDomain | 'ALL'>('ALL')
|
|
const [showOnlyRequired, setShowOnlyRequired] = useState(false)
|
|
|
|
const groupedControls = useMemo(() => getControlsGroupedByDomain(), [])
|
|
|
|
const filteredControls = useMemo(() => {
|
|
let controls = [...CONTROLS_LIBRARY]
|
|
|
|
if (selectedDomain !== 'ALL') {
|
|
controls = controls.filter((c) => c.domain === selectedDomain)
|
|
}
|
|
|
|
if (showOnlyRequired) {
|
|
controls = controls.filter((c) => c.isRequired)
|
|
}
|
|
|
|
return controls
|
|
}, [selectedDomain, showOnlyRequired])
|
|
|
|
const controlStats = useMemo(() => {
|
|
const stats = {
|
|
total: CONTROLS_LIBRARY.length,
|
|
required: CONTROLS_LIBRARY.filter((c) => c.isRequired).length,
|
|
passed: 0,
|
|
partial: 0,
|
|
failed: 0,
|
|
notAssessed: 0,
|
|
}
|
|
|
|
// Count by status across all instances
|
|
for (const instance of controlInstances) {
|
|
switch (instance.status) {
|
|
case 'PASS':
|
|
stats.passed++
|
|
break
|
|
case 'PARTIAL':
|
|
stats.partial++
|
|
break
|
|
case 'FAIL':
|
|
stats.failed++
|
|
break
|
|
default:
|
|
stats.notAssessed++
|
|
}
|
|
}
|
|
|
|
return stats
|
|
}, [controlInstances])
|
|
|
|
const getControlStatus = (controlId: string, vendorId: string): ControlStatus | null => {
|
|
const instance = controlInstances.find(
|
|
(ci) => ci.controlId === controlId && ci.entityId === vendorId && ci.entityType === 'VENDOR'
|
|
)
|
|
return instance?.status ?? null
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
|
Control-Katalog
|
|
</h1>
|
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
Standardkontrollen für Vendor- und Verarbeitungs-Compliance
|
|
</p>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
|
<StatCard
|
|
label="Gesamt"
|
|
value={controlStats.total}
|
|
color="gray"
|
|
/>
|
|
<StatCard
|
|
label="Pflicht"
|
|
value={controlStats.required}
|
|
color="blue"
|
|
/>
|
|
<StatCard
|
|
label="Bestanden"
|
|
value={controlStats.passed}
|
|
color="green"
|
|
/>
|
|
<StatCard
|
|
label="Teilweise"
|
|
value={controlStats.partial}
|
|
color="yellow"
|
|
/>
|
|
<StatCard
|
|
label="Fehlgeschlagen"
|
|
value={controlStats.failed}
|
|
color="red"
|
|
/>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
<div>
|
|
<label htmlFor="domain" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
|
Domain
|
|
</label>
|
|
<select
|
|
id="domain"
|
|
className="block w-48 pl-3 pr-10 py-2 border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
value={selectedDomain}
|
|
onChange={(e) => setSelectedDomain(e.target.value as ControlDomain | 'ALL')}
|
|
>
|
|
<option value="ALL">Alle Domains</option>
|
|
{Array.from(groupedControls.keys()).map((domain) => (
|
|
<option key={domain} value={domain}>
|
|
{getControlDomainMeta(domain).de}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-center">
|
|
<input
|
|
id="required"
|
|
type="checkbox"
|
|
checked={showOnlyRequired}
|
|
onChange={(e) => setShowOnlyRequired(e.target.checked)}
|
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
|
/>
|
|
<label htmlFor="required" className="ml-2 block text-sm text-gray-900 dark:text-white">
|
|
Nur Pflichtkontrollen
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls by Domain */}
|
|
{selectedDomain === 'ALL' ? (
|
|
// Show grouped by domain
|
|
Array.from(groupedControls.entries()).map(([domain, controls]) => {
|
|
const filteredDomainControls = showOnlyRequired
|
|
? controls.filter((c) => c.isRequired)
|
|
: controls
|
|
|
|
if (filteredDomainControls.length === 0) return null
|
|
|
|
return (
|
|
<div key={domain} className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{getControlDomainMeta(domain).de}
|
|
</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
{filteredDomainControls.length} Kontrollen
|
|
</p>
|
|
</div>
|
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{filteredDomainControls.map((control) => (
|
|
<ControlRow key={control.id} control={control} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
) : (
|
|
// Show flat list
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
{getControlDomainMeta(selectedDomain).de}
|
|
</h2>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
|
{filteredControls.length} Kontrollen
|
|
</p>
|
|
</div>
|
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
|
{filteredControls.map((control) => (
|
|
<ControlRow key={control.id} control={control} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatCard({
|
|
label,
|
|
value,
|
|
color,
|
|
}: {
|
|
label: string
|
|
value: number
|
|
color: 'gray' | 'blue' | 'green' | 'yellow' | 'red'
|
|
}) {
|
|
const colors = {
|
|
gray: 'bg-gray-50 dark:bg-gray-700/50',
|
|
blue: 'bg-blue-50 dark:bg-blue-900/20',
|
|
green: 'bg-green-50 dark:bg-green-900/20',
|
|
yellow: 'bg-yellow-50 dark:bg-yellow-900/20',
|
|
red: 'bg-red-50 dark:bg-red-900/20',
|
|
}
|
|
|
|
return (
|
|
<div className={`${colors[color]} rounded-lg p-4`}>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400">{label}</p>
|
|
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ControlRow({ control }: { control: typeof CONTROLS_LIBRARY[0] }) {
|
|
return (
|
|
<div className="px-6 py-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-mono text-gray-500 dark:text-gray-400">
|
|
{control.id}
|
|
</span>
|
|
{control.isRequired && (
|
|
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
|
Pflicht
|
|
</span>
|
|
)}
|
|
</div>
|
|
<h3 className="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
|
{control.title.de}
|
|
</h3>
|
|
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
|
{control.description.de}
|
|
</p>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{control.requirements.map((req, idx) => (
|
|
<span
|
|
key={idx}
|
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
|
>
|
|
{req}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="ml-4 text-right">
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
Prüfintervall
|
|
</p>
|
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
|
{control.defaultFrequency === 'QUARTERLY'
|
|
? 'Vierteljährlich'
|
|
: control.defaultFrequency === 'SEMI_ANNUAL'
|
|
? 'Halbjährlich'
|
|
: control.defaultFrequency === 'ANNUAL'
|
|
? 'Jährlich'
|
|
: 'Alle 2 Jahre'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700/50 rounded">
|
|
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Pass-Kriterium:</p>
|
|
<p className="text-sm text-gray-700 dark:text-gray-300">
|
|
{control.passCriteria.de}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|