Files
breakpilot-compliance/admin-compliance/app/sdk/vendor-compliance/vendors/page.tsx
Benjamin Admin 215b95adfa
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
refactor: Admin-Layout komplett entfernt — SDK als einziges Layout
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>
2026-03-04 11:43:00 +01:00

395 lines
16 KiB
TypeScript

'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>
)
}