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
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:
382
admin-compliance/app/sdk/vendor-compliance/contracts/page.tsx
Normal file
382
admin-compliance/app/sdk/vendor-compliance/contracts/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
287
admin-compliance/app/sdk/vendor-compliance/controls/page.tsx
Normal file
287
admin-compliance/app/sdk/vendor-compliance/controls/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
132
admin-compliance/app/sdk/vendor-compliance/layout.tsx
Normal file
132
admin-compliance/app/sdk/vendor-compliance/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
602
admin-compliance/app/sdk/vendor-compliance/page.tsx
Normal file
602
admin-compliance/app/sdk/vendor-compliance/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
733
admin-compliance/app/sdk/vendor-compliance/reports/page.tsx
Normal file
733
admin-compliance/app/sdk/vendor-compliance/reports/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
323
admin-compliance/app/sdk/vendor-compliance/risks/page.tsx
Normal file
323
admin-compliance/app/sdk/vendor-compliance/risks/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
394
admin-compliance/app/sdk/vendor-compliance/vendors/page.tsx
vendored
Normal file
394
admin-compliance/app/sdk/vendor-compliance/vendors/page.tsx
vendored
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user