Each page.tsx was >500 LOC (610/602/596). Extracted React components to _components/ and custom hook to _hooks/ per-route, reducing all three page.tsx orchestrators to 107/229/120 LOC respectively. Zero behavior changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
230 lines
10 KiB
TypeScript
230 lines
10 KiB
TypeScript
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { useVendorCompliance } from '@/lib/sdk/vendor-compliance'
|
|
import Link from 'next/link'
|
|
import { VendorCreateModal } from './_components/VendorCreateModal'
|
|
import { StatCard } from './_components/StatCard'
|
|
import { RiskBar } from './_components/RiskBar'
|
|
import { QuickActionCard } from './_components/QuickActionCard'
|
|
|
|
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>
|
|
|
|
{/* Faellige Reviews */}
|
|
<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>
|
|
|
|
{showVendorCreate && (
|
|
<VendorCreateModal
|
|
onClose={() => setShowVendorCreate(false)}
|
|
onSuccess={() => { setShowVendorCreate(false); window.location.reload() }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|