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>
383 lines
16 KiB
TypeScript
383 lines
16 KiB
TypeScript
'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>
|
|
)
|
|
}
|