refactor: Admin-Layout komplett entfernt — SDK als einziges Layout
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>
This commit is contained in:
Benjamin Admin
2026-03-04 11:43:00 +01:00
parent 7e5047290c
commit 215b95adfa
136 changed files with 8 additions and 8162 deletions

View File

@@ -0,0 +1,382 @@
'use client'
import { useState, useMemo } from 'react'
import Link from 'next/link'
import {
useVendorCompliance,
ContractDocument,
DocumentType,
ContractStatus,
ContractReviewStatus,
DOCUMENT_TYPE_META,
formatDate,
} from '@/lib/sdk/vendor-compliance'
export default function ContractsPage() {
const { contracts, vendors, deleteContract, startContractReview, isLoading } = useVendorCompliance()
const [searchTerm, setSearchTerm] = useState('')
const [typeFilter, setTypeFilter] = useState<DocumentType | 'ALL'>('ALL')
const [statusFilter, setStatusFilter] = useState<ContractStatus | 'ALL'>('ALL')
const [reviewFilter, setReviewFilter] = useState<ContractReviewStatus | 'ALL'>('ALL')
const filteredContracts = useMemo(() => {
let result = [...contracts]
// Search filter
if (searchTerm) {
const term = searchTerm.toLowerCase()
result = result.filter((c) => {
const vendor = vendors.find((v) => v.id === c.vendorId)
return (
c.originalName.toLowerCase().includes(term) ||
vendor?.name.toLowerCase().includes(term)
)
})
}
// Type filter
if (typeFilter !== 'ALL') {
result = result.filter((c) => c.documentType === typeFilter)
}
// Status filter
if (statusFilter !== 'ALL') {
result = result.filter((c) => c.status === statusFilter)
}
// Review filter
if (reviewFilter !== 'ALL') {
result = result.filter((c) => c.reviewStatus === reviewFilter)
}
// Sort by date (newest first)
result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
return result
}, [contracts, vendors, searchTerm, typeFilter, statusFilter, reviewFilter])
const handleDelete = async (id: string) => {
if (confirm('Möchten Sie diesen Vertrag wirklich löschen?')) {
await deleteContract(id)
}
}
const handleStartReview = async (id: string) => {
await startContractReview(id)
}
const getVendorName = (vendorId: string) => {
return vendors.find((v) => v.id === vendorId)?.name || 'Unbekannt'
}
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 className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Verträge
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
AVV, SCC und andere Verträge mit LLM-gestützter Prüfung
</p>
</div>
<Link
href="/sdk/vendor-compliance/contracts/upload"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg className="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
Vertrag hochladen
</Link>
</div>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Dateiname oder Vendor suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div>
<select
className="block w-full 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={typeFilter}
onChange={(e) => setTypeFilter(e.target.value as DocumentType | 'ALL')}
>
<option value="ALL">Alle Typen</option>
{Object.entries(DOCUMENT_TYPE_META).map(([key, value]) => (
<option key={key} value={key}>{value.de}</option>
))}
</select>
</div>
<div>
<select
className="block w-full 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={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as ContractStatus | 'ALL')}
>
<option value="ALL">Alle Status</option>
<option value="DRAFT">Entwurf</option>
<option value="SIGNED">Unterschrieben</option>
<option value="ACTIVE">Aktiv</option>
<option value="EXPIRED">Abgelaufen</option>
<option value="TERMINATED">Beendet</option>
</select>
</div>
<div>
<select
className="block w-full 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={reviewFilter}
onChange={(e) => setReviewFilter(e.target.value as ContractReviewStatus | 'ALL')}
>
<option value="ALL">Alle Reviews</option>
<option value="PENDING">Ausstehend</option>
<option value="IN_PROGRESS">In Bearbeitung</option>
<option value="COMPLETED">Abgeschlossen</option>
<option value="FAILED">Fehlgeschlagen</option>
</select>
</div>
</div>
</div>
{/* Contracts Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Dokument
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Vendor
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Typ
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Status
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Compliance
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Laufzeit
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Aktionen</span>
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredContracts.map((contract) => (
<tr key={contract.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 flex items-center justify-center bg-gray-100 dark:bg-gray-700 rounded">
<svg className="h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{contract.originalName}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
v{contract.version} {(contract.fileSize / 1024).toFixed(1)} KB
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<Link
href={`/sdk/vendor-compliance/vendors/${contract.vendorId}`}
className="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
{getVendorName(contract.vendorId)}
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
{DOCUMENT_TYPE_META[contract.documentType]?.de || contract.documentType}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<ContractStatusBadge status={contract.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<ReviewStatusBadge
reviewStatus={contract.reviewStatus}
complianceScore={contract.complianceScore}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{contract.effectiveDate ? (
<>
{formatDate(contract.effectiveDate)}
{contract.expirationDate && (
<> - {formatDate(contract.expirationDate)}</>
)}
</>
) : (
'-'
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
<Link
href={`/sdk/vendor-compliance/contracts/${contract.id}`}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400"
>
Anzeigen
</Link>
{contract.reviewStatus === 'PENDING' && (
<button
onClick={() => handleStartReview(contract.id)}
className="text-green-600 hover:text-green-900 dark:text-green-400"
>
Prüfen
</button>
)}
{contract.reviewStatus === 'COMPLETED' && (
<Link
href={`/sdk/vendor-compliance/contracts/${contract.id}/review`}
className="text-purple-600 hover:text-purple-900 dark:text-purple-400"
>
Ergebnis
</Link>
)}
<button
onClick={() => handleDelete(contract.id)}
className="text-red-600 hover:text-red-900 dark:text-red-400"
>
Löschen
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filteredContracts.length === 0 && (
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">
Keine Verträge gefunden
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Laden Sie einen Vertrag hoch, um zu beginnen.
</p>
<div className="mt-6">
<Link
href="/sdk/vendor-compliance/contracts/upload"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
Vertrag hochladen
</Link>
</div>
</div>
)}
</div>
{/* Summary */}
<div className="text-sm text-gray-500 dark:text-gray-400">
{filteredContracts.length} von {contracts.length} Verträgen
</div>
</div>
)
}
function ContractStatusBadge({ status }: { status: ContractStatus }) {
const config = {
DRAFT: { label: 'Entwurf', color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' },
SIGNED: { label: 'Unterschrieben', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
ACTIVE: { label: 'Aktiv', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
EXPIRED: { label: 'Abgelaufen', color: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200' },
TERMINATED: { label: 'Beendet', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config[status].color}`}>
{config[status].label}
</span>
)
}
function ReviewStatusBadge({
reviewStatus,
complianceScore,
}: {
reviewStatus: ContractReviewStatus
complianceScore?: number
}) {
if (reviewStatus === 'COMPLETED' && complianceScore !== undefined) {
const scoreColor =
complianceScore >= 80
? 'text-green-600 dark:text-green-400'
: complianceScore >= 60
? 'text-yellow-600 dark:text-yellow-400'
: 'text-red-600 dark:text-red-400'
return (
<div className="flex items-center gap-2">
<div className="w-16 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full ${
complianceScore >= 80
? 'bg-green-500'
: complianceScore >= 60
? 'bg-yellow-500'
: 'bg-red-500'
}`}
style={{ width: `${complianceScore}%` }}
/>
</div>
<span className={`text-sm font-medium ${scoreColor}`}>{complianceScore}%</span>
</div>
)
}
const config = {
PENDING: { label: 'Ausstehend', color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' },
IN_PROGRESS: { label: 'In Prüfung', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' },
COMPLETED: { label: 'Geprüft', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
FAILED: { label: 'Fehlgeschlagen', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config[reviewStatus].color}`}>
{config[reviewStatus].label}
</span>
)
}

View File

@@ -0,0 +1,287 @@
'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>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import { ReactNode } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { VendorComplianceProvider } from '@/lib/sdk/vendor-compliance'
interface NavItem {
href: string
label: string
icon: ReactNode
}
const navItems: NavItem[] = [
{
href: '/sdk/vendor-compliance',
label: 'Übersicht',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
},
{
href: '/sdk/vendor-compliance/processing-activities',
label: 'Verarbeitungsverzeichnis',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
},
{
href: '/sdk/vendor-compliance/vendors',
label: 'Vendor Register',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
),
},
{
href: '/sdk/vendor-compliance/contracts',
label: 'Verträge',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
},
{
href: '/sdk/vendor-compliance/risks',
label: 'Risiken',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
),
},
{
href: '/sdk/vendor-compliance/controls',
label: 'Controls',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
},
{
href: '/sdk/vendor-compliance/reports',
label: 'Berichte',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
},
]
export default function VendorComplianceLayout({
children,
}: {
children: ReactNode
}) {
const pathname = usePathname()
const isActive = (href: string) => {
if (href === '/sdk/vendor-compliance') {
return pathname === href
}
return pathname.startsWith(href)
}
return (
<VendorComplianceProvider>
<div className="flex h-full">
{/* Sidebar */}
<aside className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex-shrink-0">
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">
Vendor Compliance
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
VVT / RoPA / Verträge
</p>
</div>
<nav className="p-4 space-y-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
isActive(item.href)
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
: 'text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
}`}
>
{item.icon}
{item.label}
</Link>
))}
</nav>
</aside>
{/* Main content */}
<main className="flex-1 overflow-auto">
<div className="p-6">{children}</div>
</main>
</div>
</VendorComplianceProvider>
)
}

View File

@@ -0,0 +1,602 @@
'use client'
import { useState } from 'react'
import { useVendorCompliance } from '@/lib/sdk/vendor-compliance'
import Link from 'next/link'
// =============================================================================
// VENDOR CREATE MODAL
// =============================================================================
function VendorCreateModal({
onClose,
onSuccess
}: {
onClose: () => void
onSuccess: () => void
}) {
const [name, setName] = useState('')
const [serviceDescription, setServiceDescription] = useState('')
const [category, setCategory] = useState('data_processor')
const [country, setCountry] = useState('Germany')
const [riskLevel, setRiskLevel] = useState('MEDIUM')
const [dpaStatus, setDpaStatus] = useState('PENDING')
const [contractUrl, setContractUrl] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
if (!name.trim()) {
setError('Name ist erforderlich.')
return
}
setIsSaving(true)
setError(null)
try {
const res = await fetch('/api/sdk/v1/vendor-compliance/vendors', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
serviceDescription,
category,
country,
riskLevel,
dpaStatus,
contractUrl
})
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data.detail || data.message || `Fehler: ${res.status}`)
}
onSuccess()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setIsSaving(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-xl shadow-2xl w-full max-w-lg mx-4 p-6 z-10 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900">Neuen Vendor anlegen</h2>
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 rounded transition-colors"
>
<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>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="Name des Vendors / Dienstleisters"
/>
</div>
{/* Service Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Leistungsbeschreibung</label>
<input
type="text"
value={serviceDescription}
onChange={e => setServiceDescription(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="Kurze Beschreibung der erbrachten Leistung"
/>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<select
value={category}
onChange={e => setCategory(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
>
<option value="data_processor">Auftragsverarbeiter</option>
<option value="cloud_provider">Cloud-Anbieter</option>
<option value="saas">SaaS-Anbieter</option>
<option value="analytics">Analytics</option>
<option value="payment">Zahlungsabwicklung</option>
<option value="other">Sonstiges</option>
</select>
</div>
{/* Country */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Land</label>
<input
type="text"
value={country}
onChange={e => setCountry(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="z.B. Germany, USA, Netherlands"
/>
</div>
{/* Risk Level */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Risikostufe</label>
<select
value={riskLevel}
onChange={e => setRiskLevel(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
>
<option value="LOW">Niedrig</option>
<option value="MEDIUM">Mittel</option>
<option value="HIGH">Hoch</option>
<option value="CRITICAL">Kritisch</option>
</select>
</div>
{/* DPA Status */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">AVV-Status</label>
<select
value={dpaStatus}
onChange={e => setDpaStatus(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
>
<option value="SIGNED">Unterzeichnet</option>
<option value="PENDING">Ausstehend</option>
<option value="EXPIRED">Abgelaufen</option>
<option value="NOT_REQUIRED">Nicht erforderlich</option>
</select>
</div>
{/* Contract URL */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">AVV-Link (URL)</label>
<input
type="text"
value={contractUrl}
onChange={e => setContractUrl(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm"
placeholder="https://..."
/>
</div>
</div>
{/* Buttons */}
<div className="flex items-center justify-end gap-3 mt-6 pt-4 border-t border-gray-200">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isSaving && (
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)}
Speichern
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function VendorComplianceDashboard() {
const {
vendors,
processingActivities,
contracts,
findings,
vendorStats,
complianceStats,
riskOverview,
isLoading,
} = useVendorCompliance()
const [showVendorCreate, setShowVendorCreate] = useState(false)
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 className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Vendor & Contract Compliance
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Übersicht über Verarbeitungsverzeichnis, Vendor Register und Vertragsprüfung
</p>
</div>
<button
onClick={() => setShowVendorCreate(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Neuer Vendor
</button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="Verarbeitungstätigkeiten"
value={processingActivities.length}
description="im VVT"
href="/sdk/vendor-compliance/processing-activities"
color="blue"
/>
<StatCard
title="Vendors"
value={vendorStats.total}
description={`${vendorStats.pendingReviews} Review fällig`}
href="/sdk/vendor-compliance/vendors"
color="purple"
/>
<StatCard
title="Verträge"
value={contracts.length}
description={`${contracts.filter(c => c.reviewStatus === 'COMPLETED').length} geprüft`}
href="/sdk/vendor-compliance/contracts"
color="green"
/>
<StatCard
title="Offene Findings"
value={complianceStats.openFindings}
description={`${complianceStats.findingsBySeverity?.CRITICAL || 0} kritisch`}
href="/sdk/vendor-compliance/risks"
color="red"
/>
</div>
{/* Risk Overview */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Vendor Risk Distribution */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Vendor Risiko-Verteilung
</h2>
<div className="space-y-4">
<RiskBar
label="Kritisch"
count={vendorStats.byRiskLevel?.CRITICAL || 0}
total={vendorStats.total}
color="bg-red-500"
/>
<RiskBar
label="Hoch"
count={vendorStats.byRiskLevel?.HIGH || 0}
total={vendorStats.total}
color="bg-orange-500"
/>
<RiskBar
label="Mittel"
count={vendorStats.byRiskLevel?.MEDIUM || 0}
total={vendorStats.total}
color="bg-yellow-500"
/>
<RiskBar
label="Niedrig"
count={vendorStats.byRiskLevel?.LOW || 0}
total={vendorStats.total}
color="bg-green-500"
/>
</div>
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">
Durchschn. Inherent Risk
</span>
<span className="font-medium text-gray-900 dark:text-white">
{Math.round(riskOverview.averageInherentRisk)}%
</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-gray-500 dark:text-gray-400">
Durchschn. Residual Risk
</span>
<span className="font-medium text-gray-900 dark:text-white">
{Math.round(riskOverview.averageResidualRisk)}%
</span>
</div>
</div>
</div>
{/* Compliance Score */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Compliance Status
</h2>
<div className="flex items-center justify-center mb-6">
<div className="relative w-32 h-32">
<svg className="w-full h-full transform -rotate-90" viewBox="0 0 36 36">
<path
d="M18 2.0845
a 15.9155 15.9155 0 0 1 0 31.831
a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="#E5E7EB"
strokeWidth="3"
/>
<path
d="M18 2.0845
a 15.9155 15.9155 0 0 1 0 31.831
a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none"
stroke="#3B82F6"
strokeWidth="3"
strokeDasharray={`${complianceStats.averageComplianceScore}, 100`}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-bold text-gray-900 dark:text-white">
{Math.round(complianceStats.averageComplianceScore)}%
</span>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-green-600">
{complianceStats.resolvedFindings}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Behoben
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">
{complianceStats.openFindings}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Offen
</div>
</div>
</div>
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-between text-sm">
<span className="text-gray-500 dark:text-gray-400">
Control Pass Rate
</span>
<span className="font-medium text-gray-900 dark:text-white">
{Math.round(complianceStats.controlPassRate)}%
</span>
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<QuickActionCard
title="Neue Verarbeitung"
description="Verarbeitungstätigkeit anlegen"
href="/sdk/vendor-compliance/processing-activities"
icon={
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
}
/>
<QuickActionCard
title="Neuer Vendor"
description="Auftragsverarbeiter anlegen"
onClick={() => setShowVendorCreate(true)}
icon={
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
}
/>
<QuickActionCard
title="Vertrag hochladen"
description="AVV zur Prüfung hochladen"
href="/sdk/vendor-compliance/contracts/upload"
icon={
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
}
/>
</div>
{/* Recent Activity */}
<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">
Fällige Reviews
</h2>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{vendors
.filter((v) => v.nextReviewDate && new Date(v.nextReviewDate) <= new Date())
.slice(0, 5)
.map((vendor) => (
<Link
key={vendor.id}
href={`/sdk/vendor-compliance/vendors/${vendor.id}`}
className="block px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{vendor.name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{vendor.serviceDescription}
</p>
</div>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Review fällig
</span>
</div>
</Link>
))}
{vendors.filter((v) => v.nextReviewDate && new Date(v.nextReviewDate) <= new Date()).length === 0 && (
<div className="px-6 py-8 text-center text-gray-500 dark:text-gray-400">
Keine fälligen Reviews
</div>
)}
</div>
</div>
{/* Vendor Create Modal */}
{showVendorCreate && (
<VendorCreateModal
onClose={() => setShowVendorCreate(false)}
onSuccess={() => { setShowVendorCreate(false); window.location.reload() }}
/>
)}
</div>
)
}
function StatCard({
title,
value,
description,
href,
color,
}: {
title: string
value: number
description: string
href: string
color: 'blue' | 'purple' | 'green' | 'red'
}) {
const colors = {
blue: 'bg-blue-50 dark:bg-blue-900/20',
purple: 'bg-purple-50 dark:bg-purple-900/20',
green: 'bg-green-50 dark:bg-green-900/20',
red: 'bg-red-50 dark:bg-red-900/20',
}
return (
<Link
href={href}
className={`${colors[color]} rounded-lg p-6 hover:opacity-80 transition-opacity`}
>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
<p className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{value}</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{description}</p>
</Link>
)
}
function RiskBar({
label,
count,
total,
color,
}: {
label: string
count: number
total: number
color: string
}) {
const percentage = total > 0 ? (count / total) * 100 : 0
return (
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600 dark:text-gray-400">{label}</span>
<span className="font-medium text-gray-900 dark:text-white">{count}</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`${color} h-2 rounded-full transition-all`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
}
function QuickActionCard({
title,
description,
href,
onClick,
icon,
}: {
title: string
description: string
href?: string
onClick?: () => void
icon: React.ReactNode
}) {
const inner = (
<>
<div className="flex-shrink-0 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-blue-600 dark:text-blue-400">
{icon}
</div>
<div>
<h3 className="font-medium text-gray-900 dark:text-white">{title}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">{description}</p>
</div>
</>
)
if (onClick) {
return (
<button
onClick={onClick}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-start gap-4 w-full text-left"
>
{inner}
</button>
)
}
return (
<Link
href={href!}
className="bg-white dark:bg-gray-800 rounded-lg shadow p-6 hover:shadow-md transition-shadow flex items-start gap-4"
>
{inner}
</Link>
)
}

View File

@@ -0,0 +1,425 @@
'use client'
import { useState, useMemo } from 'react'
import Link from 'next/link'
import {
useVendorCompliance,
ProcessingActivity,
ProcessingActivityStatus,
ProtectionLevel,
DATA_SUBJECT_CATEGORY_META,
PERSONAL_DATA_CATEGORY_META,
getStatusColor,
formatDate,
} from '@/lib/sdk/vendor-compliance'
type SortField = 'vvtId' | 'name' | 'status' | 'protectionLevel' | 'updatedAt'
type SortOrder = 'asc' | 'desc'
export default function ProcessingActivitiesPage() {
const { processingActivities, deleteProcessingActivity, duplicateProcessingActivity, isLoading } = useVendorCompliance()
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState<ProcessingActivityStatus | 'ALL'>('ALL')
const [protectionFilter, setProtectionFilter] = useState<ProtectionLevel | 'ALL'>('ALL')
const [sortField, setSortField] = useState<SortField>('vvtId')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const filteredActivities = useMemo(() => {
let result = [...processingActivities]
// Search filter
if (searchTerm) {
const term = searchTerm.toLowerCase()
result = result.filter(
(a) =>
a.vvtId.toLowerCase().includes(term) ||
a.name.de.toLowerCase().includes(term) ||
a.name.en.toLowerCase().includes(term)
)
}
// Status filter
if (statusFilter !== 'ALL') {
result = result.filter((a) => a.status === statusFilter)
}
// Protection level filter
if (protectionFilter !== 'ALL') {
result = result.filter((a) => a.protectionLevel === protectionFilter)
}
// Sort
result.sort((a, b) => {
let comparison = 0
switch (sortField) {
case 'vvtId':
comparison = a.vvtId.localeCompare(b.vvtId)
break
case 'name':
comparison = a.name.de.localeCompare(b.name.de)
break
case 'status':
comparison = a.status.localeCompare(b.status)
break
case 'protectionLevel':
const levels = { LOW: 1, MEDIUM: 2, HIGH: 3 }
comparison = levels[a.protectionLevel] - levels[b.protectionLevel]
break
case 'updatedAt':
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
break
}
return sortOrder === 'asc' ? comparison : -comparison
})
return result
}, [processingActivities, searchTerm, statusFilter, protectionFilter, sortField, sortOrder])
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortOrder('asc')
}
}
const handleDelete = async (id: string) => {
if (confirm('Möchten Sie diese Verarbeitungstätigkeit wirklich löschen?')) {
await deleteProcessingActivity(id)
}
}
const handleDuplicate = async (id: string) => {
await duplicateProcessingActivity(id)
}
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 className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Verarbeitungsverzeichnis (VVT)
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Art. 30 DSGVO - Verzeichnis von Verarbeitungstätigkeiten
</p>
</div>
<Link
href="/sdk/vendor-compliance/processing-activities/new"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg className="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Verarbeitung
</Link>
</div>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Search */}
<div className="md:col-span-2">
<label htmlFor="search" className="sr-only">Suchen</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
id="search"
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md leading-5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="VVT-ID oder Name suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Status Filter */}
<div>
<label htmlFor="status" className="sr-only">Status</label>
<select
id="status"
className="block w-full pl-3 pr-10 py-2 text-base 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={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as ProcessingActivityStatus | 'ALL')}
>
<option value="ALL">Alle Status</option>
<option value="DRAFT">Entwurf</option>
<option value="REVIEW">In Prüfung</option>
<option value="APPROVED">Freigegeben</option>
<option value="ARCHIVED">Archiviert</option>
</select>
</div>
{/* Protection Level Filter */}
<div>
<label htmlFor="protection" className="sr-only">Schutzbedarf</label>
<select
id="protection"
className="block w-full pl-3 pr-10 py-2 text-base 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={protectionFilter}
onChange={(e) => setProtectionFilter(e.target.value as ProtectionLevel | 'ALL')}
>
<option value="ALL">Alle Schutzbedarfe</option>
<option value="LOW">Niedrig</option>
<option value="MEDIUM">Mittel</option>
<option value="HIGH">Hoch</option>
</select>
</div>
</div>
</div>
{/* Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
onClick={() => handleSort('vvtId')}
>
<div className="flex items-center gap-1">
VVT-ID
<SortIcon field="vvtId" currentField={sortField} order={sortOrder} />
</div>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
onClick={() => handleSort('name')}
>
<div className="flex items-center gap-1">
Name
<SortIcon field="name" currentField={sortField} order={sortOrder} />
</div>
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Betroffene
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Datenkategorien
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
onClick={() => handleSort('protectionLevel')}
>
<div className="flex items-center gap-1">
Schutzbedarf
<SortIcon field="protectionLevel" currentField={sortField} order={sortOrder} />
</div>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600"
onClick={() => handleSort('status')}
>
<div className="flex items-center gap-1">
Status
<SortIcon field="status" currentField={sortField} order={sortOrder} />
</div>
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Aktionen</span>
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{filteredActivities.map((activity) => (
<tr key={activity.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{activity.vvtId}
</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{activity.name.de}
</div>
{activity.name.en && activity.name.en !== activity.name.de && (
<div className="text-sm text-gray-500 dark:text-gray-400">
{activity.name.en}
</div>
)}
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{activity.dataSubjectCategories.slice(0, 2).map((cat) => (
<span
key={cat}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200"
>
{DATA_SUBJECT_CATEGORY_META[cat]?.de || cat}
</span>
))}
{activity.dataSubjectCategories.length > 2 && (
<span className="text-xs text-gray-500 dark:text-gray-400">
+{activity.dataSubjectCategories.length - 2}
</span>
)}
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{activity.personalDataCategories.slice(0, 2).map((cat) => (
<span
key={cat}
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
PERSONAL_DATA_CATEGORY_META[cat]?.isSpecial
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
}`}
>
{PERSONAL_DATA_CATEGORY_META[cat]?.label.de || cat}
</span>
))}
{activity.personalDataCategories.length > 2 && (
<span className="text-xs text-gray-500 dark:text-gray-400">
+{activity.personalDataCategories.length - 2}
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<ProtectionLevelBadge level={activity.protectionLevel} />
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={activity.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-2">
<Link
href={`/sdk/vendor-compliance/processing-activities/${activity.id}`}
className="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"
>
Bearbeiten
</Link>
<button
onClick={() => handleDuplicate(activity.id)}
className="text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300"
>
Duplizieren
</button>
<button
onClick={() => handleDelete(activity.id)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
>
Löschen
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredActivities.length === 0 && (
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">
Keine Verarbeitungstätigkeiten
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Erstellen Sie eine neue Verarbeitungstätigkeit, um zu beginnen.
</p>
<div className="mt-6">
<Link
href="/sdk/vendor-compliance/processing-activities/new"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
<svg className="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Verarbeitung
</Link>
</div>
</div>
)}
</div>
{/* Summary */}
<div className="text-sm text-gray-500 dark:text-gray-400">
{filteredActivities.length} von {processingActivities.length} Verarbeitungstätigkeiten
</div>
</div>
)
}
function SortIcon({ field, currentField, order }: { field: SortField; currentField: SortField; order: SortOrder }) {
if (field !== currentField) {
return (
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
)
}
return order === 'asc' ? (
<svg className="w-4 h-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
) : (
<svg className="w-4 h-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)
}
function StatusBadge({ status }: { status: ProcessingActivityStatus }) {
const statusConfig = {
DRAFT: { label: 'Entwurf', color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' },
REVIEW: { label: 'In Prüfung', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
APPROVED: { label: 'Freigegeben', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
ARCHIVED: { label: 'Archiviert', color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' },
}
const config = statusConfig[status]
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config.color}`}>
{config.label}
</span>
)
}
function ProtectionLevelBadge({ level }: { level: ProtectionLevel }) {
const config = {
LOW: { label: 'Niedrig', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
MEDIUM: { label: 'Mittel', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
HIGH: { label: 'Hoch', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${config[level].color}`}>
{config[level].label}
</span>
)
}

View File

@@ -0,0 +1,733 @@
'use client'
import { useState, useMemo } from 'react'
import {
useVendorCompliance,
ReportType,
ExportFormat,
ProcessingActivity,
Vendor,
} from '@/lib/sdk/vendor-compliance'
interface ExportConfig {
reportType: ReportType
format: ExportFormat
scope: {
vendorIds: string[]
processingActivityIds: string[]
includeFindings: boolean
includeControls: boolean
includeRiskAssessment: boolean
dateRange?: {
from: string
to: string
}
}
}
const REPORT_TYPE_META: Record<
ReportType,
{
title: string
description: string
icon: string
formats: ExportFormat[]
defaultFormat: ExportFormat
}
> = {
VVT_EXPORT: {
title: 'Verarbeitungsverzeichnis (VVT)',
description:
'Vollständiges Verarbeitungsverzeichnis gemäß Art. 30 DSGVO mit allen Pflichtangaben',
icon: '📋',
formats: ['PDF', 'DOCX', 'XLSX'],
defaultFormat: 'DOCX',
},
ROPA: {
title: 'Records of Processing Activities (RoPA)',
description:
'Processor-Perspektive: Alle Verarbeitungen als Auftragsverarbeiter',
icon: '📝',
formats: ['PDF', 'DOCX', 'XLSX'],
defaultFormat: 'DOCX',
},
VENDOR_AUDIT: {
title: 'Vendor Audit Pack',
description:
'Vollständige Dokumentation eines Vendors inkl. Verträge, Findings und Risikobewertung',
icon: '🔍',
formats: ['PDF', 'DOCX'],
defaultFormat: 'PDF',
},
MANAGEMENT_SUMMARY: {
title: 'Management Summary',
description:
'Übersicht für die Geschäftsführung: Risiken, offene Findings, Compliance-Status',
icon: '📊',
formats: ['PDF', 'DOCX', 'XLSX'],
defaultFormat: 'PDF',
},
DPIA_INPUT: {
title: 'DSFA-Input',
description:
'Vorbereitete Daten für eine Datenschutz-Folgenabschätzung (DSFA/DPIA)',
icon: '⚠️',
formats: ['PDF', 'DOCX'],
defaultFormat: 'DOCX',
},
}
const FORMAT_META: Record<ExportFormat, { label: string; icon: string }> = {
PDF: { label: 'PDF', icon: '📄' },
DOCX: { label: 'Word (DOCX)', icon: '📝' },
XLSX: { label: 'Excel (XLSX)', icon: '📊' },
JSON: { label: 'JSON', icon: '🔧' },
}
export default function ReportsPage() {
const {
processingActivities,
vendors,
contracts,
findings,
riskAssessments,
isLoading,
} = useVendorCompliance()
const [selectedReportType, setSelectedReportType] = useState<ReportType>('VVT_EXPORT')
const [selectedFormat, setSelectedFormat] = useState<ExportFormat>('DOCX')
const [selectedVendors, setSelectedVendors] = useState<string[]>([])
const [selectedActivities, setSelectedActivities] = useState<string[]>([])
const [includeFindings, setIncludeFindings] = useState(true)
const [includeControls, setIncludeControls] = useState(true)
const [includeRiskAssessment, setIncludeRiskAssessment] = useState(true)
const [isGenerating, setIsGenerating] = useState(false)
const [generatedReports, setGeneratedReports] = useState<
{ id: string; type: ReportType; format: ExportFormat; generatedAt: Date; filename: string }[]
>([])
const reportMeta = REPORT_TYPE_META[selectedReportType]
// Update format when report type changes
const handleReportTypeChange = (type: ReportType) => {
setSelectedReportType(type)
setSelectedFormat(REPORT_TYPE_META[type].defaultFormat)
// Reset selections
setSelectedVendors([])
setSelectedActivities([])
}
// Calculate statistics
const stats = useMemo(() => {
const openFindings = findings.filter((f) => f.status === 'OPEN').length
const criticalFindings = findings.filter(
(f) => f.status === 'OPEN' && f.severity === 'CRITICAL'
).length
const highRiskVendors = vendors.filter((v) => v.inherentRiskScore >= 70).length
return {
totalActivities: processingActivities.length,
approvedActivities: processingActivities.filter((a) => a.status === 'APPROVED').length,
totalVendors: vendors.length,
activeVendors: vendors.filter((v) => v.status === 'ACTIVE').length,
totalContracts: contracts.length,
openFindings,
criticalFindings,
highRiskVendors,
}
}, [processingActivities, vendors, contracts, findings])
// Handle export
const handleExport = async () => {
setIsGenerating(true)
try {
const config: ExportConfig = {
reportType: selectedReportType,
format: selectedFormat,
scope: {
vendorIds: selectedVendors,
processingActivityIds: selectedActivities,
includeFindings,
includeControls,
includeRiskAssessment,
},
}
// Call API to generate report
const response = await fetch('/api/sdk/v1/vendor-compliance/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
})
if (!response.ok) {
throw new Error('Export fehlgeschlagen')
}
const result = await response.json()
// Add to generated reports
setGeneratedReports((prev) => [
{
id: result.id,
type: selectedReportType,
format: selectedFormat,
generatedAt: new Date(),
filename: result.filename,
},
...prev,
])
// Download the file
if (result.downloadUrl) {
window.open(result.downloadUrl, '_blank')
}
} catch (error) {
console.error('Export error:', error)
// Show error notification
} finally {
setIsGenerating(false)
}
}
// Toggle vendor selection
const toggleVendor = (vendorId: string) => {
setSelectedVendors((prev) =>
prev.includes(vendorId)
? prev.filter((id) => id !== vendorId)
: [...prev, vendorId]
)
}
// Toggle activity selection
const toggleActivity = (activityId: string) => {
setSelectedActivities((prev) =>
prev.includes(activityId)
? prev.filter((id) => id !== activityId)
: [...prev, activityId]
)
}
// Select all vendors
const selectAllVendors = () => {
setSelectedVendors(vendors.map((v) => v.id))
}
// Select all activities
const selectAllActivities = () => {
setSelectedActivities(processingActivities.map((a) => a.id))
}
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">
Reports & Export
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Berichte erstellen und Daten exportieren
</p>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
label="Verarbeitungen"
value={stats.totalActivities}
subtext={`${stats.approvedActivities} freigegeben`}
color="blue"
/>
<StatCard
label="Vendors"
value={stats.totalVendors}
subtext={`${stats.highRiskVendors} hohes Risiko`}
color="purple"
/>
<StatCard
label="Offene Findings"
value={stats.openFindings}
subtext={`${stats.criticalFindings} kritisch`}
color={stats.criticalFindings > 0 ? 'red' : 'yellow'}
/>
<StatCard
label="Verträge"
value={stats.totalContracts}
subtext="dokumentiert"
color="green"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Report Type Selection */}
<div className="lg:col-span-2 space-y-6">
{/* Report Type Cards */}
<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">
Report-Typ wählen
</h2>
</div>
<div className="p-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
{(Object.entries(REPORT_TYPE_META) as [ReportType, typeof REPORT_TYPE_META[ReportType]][]).map(
([type, meta]) => (
<button
key={type}
onClick={() => handleReportTypeChange(type)}
className={`p-4 rounded-lg border-2 text-left transition-all ${
selectedReportType === type
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<div className="flex items-center gap-3">
<span className="text-2xl">{meta.icon}</span>
<div>
<h3 className="font-medium text-gray-900 dark:text-white">
{meta.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{meta.description}
</p>
</div>
</div>
</button>
)
)}
</div>
</div>
{/* Scope Selection */}
{(selectedReportType === 'VVT_EXPORT' || selectedReportType === 'ROPA' || selectedReportType === 'DPIA_INPUT') && (
<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 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Verarbeitungen auswählen
</h2>
<button
onClick={selectAllActivities}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
Alle auswählen
</button>
</div>
<div className="p-4 max-h-64 overflow-y-auto">
{processingActivities.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
Keine Verarbeitungen vorhanden
</p>
) : (
<div className="space-y-2">
{processingActivities.map((activity) => (
<label
key={activity.id}
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedActivities.includes(activity.id)}
onChange={() => toggleActivity(activity.id)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{activity.name.de}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{activity.vvtId} · {activity.status}
</p>
</div>
<StatusBadge status={activity.status} />
</label>
))}
</div>
)}
</div>
</div>
)}
{selectedReportType === 'VENDOR_AUDIT' && (
<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 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Vendor auswählen
</h2>
<button
onClick={selectAllVendors}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
Alle auswählen
</button>
</div>
<div className="p-4 max-h-64 overflow-y-auto">
{vendors.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
Keine Vendors vorhanden
</p>
) : (
<div className="space-y-2">
{vendors.map((vendor) => (
<label
key={vendor.id}
className="flex items-center gap-3 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedVendors.includes(vendor.id)}
onChange={() => toggleVendor(vendor.id)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{vendor.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{vendor.country} · {vendor.serviceCategory}
</p>
</div>
<RiskBadge score={vendor.inherentRiskScore} />
</label>
))}
</div>
)}
</div>
</div>
)}
{/* Include Options */}
<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">
Optionen
</h2>
</div>
<div className="p-4 space-y-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={includeFindings}
onChange={(e) => setIncludeFindings(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
Findings einbeziehen
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Offene und behobene Vertragsprüfungs-Findings
</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={includeControls}
onChange={(e) => setIncludeControls(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
Control-Status einbeziehen
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Übersicht aller Kontrollen und deren Erfüllungsstatus
</p>
</div>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={includeRiskAssessment}
onChange={(e) => setIncludeRiskAssessment(e.target.checked)}
className="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500"
/>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
Risikobewertung einbeziehen
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
Inhärentes und Restrisiko mit Begründung
</p>
</div>
</label>
</div>
</div>
</div>
{/* Export Panel */}
<div className="space-y-6">
{/* Format & Export */}
<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">
Export
</h2>
</div>
<div className="p-4 space-y-4">
{/* Selected Report Info */}
<div className="p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{reportMeta.icon}</span>
<span className="font-medium text-gray-900 dark:text-white">
{reportMeta.title}
</span>
</div>
<p className="text-sm text-gray-500 dark:text-gray-400">
{reportMeta.description}
</p>
</div>
{/* Format Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Format
</label>
<div className="flex flex-wrap gap-2">
{reportMeta.formats.map((format) => (
<button
key={format}
onClick={() => setSelectedFormat(format)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
selectedFormat === format
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
{FORMAT_META[format].icon} {FORMAT_META[format].label}
</button>
))}
</div>
</div>
{/* Scope Summary */}
<div className="text-sm text-gray-500 dark:text-gray-400">
<p>
{selectedReportType === 'VENDOR_AUDIT'
? `${selectedVendors.length || 'Alle'} Vendor(s) ausgewählt`
: selectedReportType === 'MANAGEMENT_SUMMARY'
? 'Gesamtübersicht'
: `${selectedActivities.length || 'Alle'} Verarbeitung(en) ausgewählt`}
</p>
</div>
{/* Export Button */}
<button
onClick={handleExport}
disabled={isGenerating}
className={`w-full py-3 px-4 rounded-lg font-medium text-white transition-colors ${
isGenerating
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
>
{isGenerating ? (
<span className="flex items-center justify-center gap-2">
<svg
className="animate-spin h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Wird generiert...
</span>
) : (
`${reportMeta.title} exportieren`
)}
</button>
</div>
</div>
{/* Recent Reports */}
<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">
Letzte Reports
</h2>
</div>
<div className="p-4">
{generatedReports.length === 0 ? (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
Noch keine Reports generiert
</p>
) : (
<div className="space-y-3">
{generatedReports.slice(0, 5).map((report) => (
<div
key={report.id}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
>
<div className="flex items-center gap-3">
<span className="text-lg">
{REPORT_TYPE_META[report.type].icon}
</span>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{report.filename}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{report.generatedAt.toLocaleString('de-DE')}
</p>
</div>
</div>
<button className="text-blue-600 hover:text-blue-800 dark:text-blue-400">
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* Help / Templates */}
<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">
Hilfe
</h2>
</div>
<div className="p-4 space-y-3 text-sm">
<div className="flex gap-2">
<span>📋</span>
<div>
<p className="font-medium text-gray-900 dark:text-white">
VVT Export
</p>
<p className="text-gray-500 dark:text-gray-400">
Art. 30 DSGVO konformes Verzeichnis aller
Verarbeitungstätigkeiten
</p>
</div>
</div>
<div className="flex gap-2">
<span>🔍</span>
<div>
<p className="font-medium text-gray-900 dark:text-white">
Vendor Audit
</p>
<p className="text-gray-500 dark:text-gray-400">
Komplette Dokumentation für Due Diligence und Audits
</p>
</div>
</div>
<div className="flex gap-2">
<span>📊</span>
<div>
<p className="font-medium text-gray-900 dark:text-white">
Management Summary
</p>
<p className="text-gray-500 dark:text-gray-400">
Übersicht für Geschäftsführung und DSB
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
// Helper Components
function StatCard({
label,
value,
subtext,
color,
}: {
label: string
value: number
subtext: string
color: 'blue' | 'purple' | 'green' | 'yellow' | 'red'
}) {
const colors = {
blue: 'bg-blue-50 dark:bg-blue-900/20',
purple: 'bg-purple-50 dark:bg-purple-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>
<p className="text-xs text-gray-500 dark:text-gray-400">{subtext}</p>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const statusStyles: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
REVIEW: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
APPROVED: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
ARCHIVED: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
}
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[status] || statusStyles.DRAFT
}`}
>
{status}
</span>
)
}
function RiskBadge({ score }: { score: number }) {
let colorClass = 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
if (score >= 70) {
colorClass = 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
} else if (score >= 50) {
colorClass = 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
}
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${colorClass}`}
>
{score}
</span>
)
}

View File

@@ -0,0 +1,323 @@
'use client'
import { useMemo } from 'react'
import Link from 'next/link'
import {
useVendorCompliance,
getRiskLevelFromScore,
getRiskLevelColor,
generateRiskMatrix,
SEVERITY_DEFINITIONS,
countFindingsBySeverity,
} from '@/lib/sdk/vendor-compliance'
export default function RisksPage() {
const { vendors, findings, riskOverview, isLoading } = useVendorCompliance()
const riskMatrix = useMemo(() => generateRiskMatrix(), [])
const findingsBySeverity = useMemo(() => {
return countFindingsBySeverity(findings)
}, [findings])
const openFindings = useMemo(() => {
return findings.filter((f) => f.status === 'OPEN' || f.status === 'IN_PROGRESS')
}, [findings])
const highRiskVendors = useMemo(() => {
return vendors.filter((v) => v.residualRiskScore >= 60)
}, [vendors])
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">
Risiko-Dashboard
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Übersicht über Vendor-Risiken und offene Findings
</p>
</div>
{/* Risk Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<RiskCard
title="Durchschn. Inherent Risk"
value={Math.round(riskOverview.averageInherentRisk)}
suffix="%"
color="purple"
/>
<RiskCard
title="Durchschn. Residual Risk"
value={Math.round(riskOverview.averageResidualRisk)}
suffix="%"
color="blue"
/>
<RiskCard
title="High-Risk Vendors"
value={riskOverview.highRiskVendors}
color="red"
/>
<RiskCard
title="Kritische Findings"
value={riskOverview.criticalFindings}
color="orange"
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Risk Matrix */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Risikomatrix
</h2>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr>
<th className="text-xs text-gray-500 dark:text-gray-400 font-medium pb-2"></th>
{[1, 2, 3, 4, 5].map((impact) => (
<th
key={impact}
className="text-xs text-gray-500 dark:text-gray-400 font-medium pb-2 text-center"
>
{impact}
</th>
))}
</tr>
</thead>
<tbody>
{[5, 4, 3, 2, 1].map((likelihood) => (
<tr key={likelihood}>
<td className="text-xs text-gray-500 dark:text-gray-400 font-medium pr-2 text-right">
{likelihood}
</td>
{[1, 2, 3, 4, 5].map((impact) => {
const cell = riskMatrix[likelihood - 1]?.[impact - 1]
const colors = cell ? getRiskLevelColor(cell.level) : { bg: '', text: '' }
const vendorCount = vendors.filter((v) => {
const vLikelihood = Math.ceil(v.residualRiskScore / 20)
const vImpact = Math.ceil(v.inherentRiskScore / 20)
return vLikelihood === likelihood && vImpact === impact
}).length
return (
<td key={impact} className="p-1">
<div
className={`${colors.bg} ${colors.text} rounded p-2 text-center min-w-[40px]`}
>
<span className="text-sm font-medium">
{vendorCount > 0 ? vendorCount : '-'}
</span>
</div>
</td>
)
})}
</tr>
))}
</tbody>
</table>
<div className="mt-4 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span>Eintrittswahrscheinlichkeit </span>
<span>Auswirkung </span>
</div>
</div>
</div>
{/* Findings by Severity */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Findings nach Schweregrad
</h2>
<div className="space-y-4">
{(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const).map((severity) => {
const count = findingsBySeverity[severity] || 0
const total = findings.length
const percentage = total > 0 ? (count / total) * 100 : 0
const def = SEVERITY_DEFINITIONS[severity]
return (
<div key={severity}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{def.label.de}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{count}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full ${
severity === 'CRITICAL'
? 'bg-red-500'
: severity === 'HIGH'
? 'bg-orange-500'
: severity === 'MEDIUM'
? 'bg-yellow-500'
: 'bg-blue-500'
}`}
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{def.responseTime.de}
</p>
</div>
)
})}
</div>
</div>
</div>
{/* High Risk Vendors */}
<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">
High-Risk Vendors
</h2>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{highRiskVendors.map((vendor) => {
const riskLevel = getRiskLevelFromScore(vendor.residualRiskScore / 4)
const colors = getRiskLevelColor(riskLevel)
return (
<Link
key={vendor.id}
href={`/sdk/vendor-compliance/vendors/${vendor.id}`}
className="block px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{vendor.name}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{vendor.serviceDescription}
</p>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-xs text-gray-500 dark:text-gray-400">
Residual Risk
</p>
<p className={`text-lg font-bold ${colors.text}`}>
{vendor.residualRiskScore}%
</p>
</div>
<span className={`${colors.bg} ${colors.text} px-3 py-1 rounded-full text-sm font-medium`}>
{riskLevel}
</span>
</div>
</div>
</Link>
)
})}
{highRiskVendors.length === 0 && (
<div className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
Keine High-Risk Vendors vorhanden
</div>
)}
</div>
</div>
{/* Open Findings */}
<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 flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Offene Findings ({openFindings.length})
</h2>
</div>
<div className="divide-y divide-gray-200 dark:divide-gray-700">
{openFindings.slice(0, 10).map((finding) => {
const vendor = vendors.find((v) => v.id === finding.vendorId)
const severityColors = {
LOW: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
MEDIUM: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
HIGH: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
CRITICAL: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
}
return (
<div key={finding.id} 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={`px-2 py-0.5 rounded-full text-xs font-medium ${severityColors[finding.severity]}`}>
{finding.severity}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{finding.category}
</span>
</div>
<p className="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{finding.title.de}
</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{finding.description.de}
</p>
{vendor && (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Vendor: {vendor.name}
</p>
)}
</div>
<Link
href={`/sdk/vendor-compliance/contracts/${finding.contractId}`}
className="text-sm text-blue-600 hover:text-blue-500 dark:text-blue-400 ml-4"
>
Details
</Link>
</div>
</div>
)
})}
{openFindings.length === 0 && (
<div className="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
Keine offenen Findings
</div>
)}
</div>
</div>
</div>
)
}
function RiskCard({
title,
value,
suffix,
color,
}: {
title: string
value: number
suffix?: string
color: 'purple' | 'blue' | 'red' | 'orange'
}) {
const colors = {
purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
orange: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
}
return (
<div className={`${colors[color]} rounded-lg p-6`}>
<p className="text-sm font-medium opacity-80">{title}</p>
<p className="mt-2 text-3xl font-bold">
{value}
{suffix}
</p>
</div>
)
}

View File

@@ -0,0 +1,394 @@
'use client'
import { useState, useMemo } from 'react'
import Link from 'next/link'
import {
useVendorCompliance,
Vendor,
VendorStatus,
VendorRole,
ServiceCategory,
VENDOR_ROLE_META,
SERVICE_CATEGORY_META,
getRiskLevelFromScore,
formatDate,
} from '@/lib/sdk/vendor-compliance'
type SortField = 'name' | 'role' | 'status' | 'riskScore' | 'nextReviewDate'
type SortOrder = 'asc' | 'desc'
export default function VendorsPage() {
const { vendors, contracts, deleteVendor, isLoading } = useVendorCompliance()
const [searchTerm, setSearchTerm] = useState('')
const [statusFilter, setStatusFilter] = useState<VendorStatus | 'ALL'>('ALL')
const [roleFilter, setRoleFilter] = useState<VendorRole | 'ALL'>('ALL')
const [categoryFilter, setCategoryFilter] = useState<ServiceCategory | 'ALL'>('ALL')
const [sortField, setSortField] = useState<SortField>('name')
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const filteredVendors = useMemo(() => {
let result = [...vendors]
// Search filter
if (searchTerm) {
const term = searchTerm.toLowerCase()
result = result.filter(
(v) =>
v.name.toLowerCase().includes(term) ||
v.serviceDescription.toLowerCase().includes(term)
)
}
// Status filter
if (statusFilter !== 'ALL') {
result = result.filter((v) => v.status === statusFilter)
}
// Role filter
if (roleFilter !== 'ALL') {
result = result.filter((v) => v.role === roleFilter)
}
// Category filter
if (categoryFilter !== 'ALL') {
result = result.filter((v) => v.serviceCategory === categoryFilter)
}
// Sort
result.sort((a, b) => {
let comparison = 0
switch (sortField) {
case 'name':
comparison = a.name.localeCompare(b.name)
break
case 'role':
comparison = a.role.localeCompare(b.role)
break
case 'status':
comparison = a.status.localeCompare(b.status)
break
case 'riskScore':
comparison = a.residualRiskScore - b.residualRiskScore
break
case 'nextReviewDate':
const dateA = a.nextReviewDate ? new Date(a.nextReviewDate).getTime() : Infinity
const dateB = b.nextReviewDate ? new Date(b.nextReviewDate).getTime() : Infinity
comparison = dateA - dateB
break
}
return sortOrder === 'asc' ? comparison : -comparison
})
return result
}, [vendors, searchTerm, statusFilter, roleFilter, categoryFilter, sortField, sortOrder])
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortOrder('asc')
}
}
const handleDelete = async (id: string) => {
if (confirm('Möchten Sie diesen Vendor wirklich löschen?')) {
await deleteVendor(id)
}
}
const getContractCount = (vendorId: string) => {
return contracts.filter((c) => c.vendorId === vendorId).length
}
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 className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Vendor Register
</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Verwaltung von Auftragsverarbeitern und Dienstleistern
</p>
</div>
<Link
href="/sdk/vendor-compliance/vendors/new"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<svg className="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neuer Vendor
</Link>
</div>
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{/* Search */}
<div className="md:col-span-2">
<label htmlFor="search" className="sr-only">Suchen</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg className="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input
type="text"
id="search"
className="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md leading-5 bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
placeholder="Name oder Beschreibung suchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
{/* Status Filter */}
<div>
<select
className="block w-full pl-3 pr-10 py-2 text-base 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={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as VendorStatus | 'ALL')}
>
<option value="ALL">Alle Status</option>
<option value="ACTIVE">Aktiv</option>
<option value="INACTIVE">Inaktiv</option>
<option value="PENDING_REVIEW">Review ausstehend</option>
<option value="TERMINATED">Beendet</option>
</select>
</div>
{/* Role Filter */}
<div>
<select
className="block w-full pl-3 pr-10 py-2 text-base 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={roleFilter}
onChange={(e) => setRoleFilter(e.target.value as VendorRole | 'ALL')}
>
<option value="ALL">Alle Rollen</option>
<option value="PROCESSOR">Auftragsverarbeiter</option>
<option value="SUB_PROCESSOR">Unterauftragnehmer</option>
<option value="CONTROLLER">Verantwortlicher</option>
<option value="JOINT_CONTROLLER">Gemeinsam Verantwortlicher</option>
<option value="THIRD_PARTY">Dritter</option>
</select>
</div>
{/* Category Filter */}
<div>
<select
className="block w-full pl-3 pr-10 py-2 text-base 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={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as ServiceCategory | 'ALL')}
>
<option value="ALL">Alle Kategorien</option>
{Object.entries(SERVICE_CATEGORY_META).map(([key, value]) => (
<option key={key} value={key}>
{value.de}
</option>
))}
</select>
</div>
</div>
</div>
{/* Vendor Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredVendors.map((vendor) => (
<VendorCard
key={vendor.id}
vendor={vendor}
contractCount={getContractCount(vendor.id)}
onDelete={handleDelete}
/>
))}
</div>
{filteredVendors.length === 0 && (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg shadow">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900 dark:text-white">
Keine Vendors gefunden
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Erstellen Sie einen neuen Vendor, um zu beginnen.
</p>
<div className="mt-6">
<Link
href="/sdk/vendor-compliance/vendors/new"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
<svg className="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neuer Vendor
</Link>
</div>
</div>
)}
{/* Summary */}
<div className="text-sm text-gray-500 dark:text-gray-400">
{filteredVendors.length} von {vendors.length} Vendors
</div>
</div>
)
}
function VendorCard({
vendor,
contractCount,
onDelete,
}: {
vendor: Vendor
contractCount: number
onDelete: (id: string) => void
}) {
const riskLevel = getRiskLevelFromScore(vendor.residualRiskScore / 4)
const isReviewDue = vendor.nextReviewDate && new Date(vendor.nextReviewDate) <= new Date()
const riskColors = {
LOW: 'border-l-green-500',
MEDIUM: 'border-l-yellow-500',
HIGH: 'border-l-orange-500',
CRITICAL: 'border-l-red-500',
}
const statusConfig = {
ACTIVE: { label: 'Aktiv', color: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' },
INACTIVE: { label: 'Inaktiv', color: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200' },
PENDING_REVIEW: { label: 'Review ausstehend', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' },
TERMINATED: { label: 'Beendet', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' },
}
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow border-l-4 ${riskColors[riskLevel]} overflow-hidden`}>
<div className="p-5">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">
{vendor.name}
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{SERVICE_CATEGORY_META[vendor.serviceCategory]?.de}
</p>
</div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusConfig[vendor.status].color}`}>
{statusConfig[vendor.status].label}
</span>
</div>
<p className="mt-3 text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
{vendor.serviceDescription}
</p>
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Rolle</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{VENDOR_ROLE_META[vendor.role]?.de}
</p>
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Risiko-Score</p>
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className={`h-2 rounded-full ${
riskLevel === 'LOW'
? 'bg-green-500'
: riskLevel === 'MEDIUM'
? 'bg-yellow-500'
: riskLevel === 'HIGH'
? 'bg-orange-500'
: 'bg-red-500'
}`}
style={{ width: `${vendor.residualRiskScore}%` }}
/>
</div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{vendor.residualRiskScore}
</span>
</div>
</div>
</div>
<div className="mt-4 flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{contractCount} Verträge
</span>
{vendor.certifications.length > 0 && (
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
{vendor.certifications.length} Zertifizierungen
</span>
)}
</div>
{isReviewDue && (
<div className="mt-3 p-2 bg-red-50 dark:bg-red-900/20 rounded-md">
<p className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
Review fällig seit {formatDate(vendor.nextReviewDate)}
</p>
</div>
)}
</div>
<div className="px-5 py-3 bg-gray-50 dark:bg-gray-700/50 flex items-center justify-between">
<Link
href={`/sdk/vendor-compliance/vendors/${vendor.id}`}
className="text-sm font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400"
>
Details anzeigen
</Link>
<div className="flex items-center gap-3">
<Link
href={`/sdk/vendor-compliance/vendors/${vendor.id}/contracts`}
className="text-sm text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-300"
>
Verträge
</Link>
<button
onClick={() => onDelete(vendor.id)}
className="text-sm text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
>
Löschen
</button>
</div>
</div>
</div>
)
}