Whistleblower (1220 -> 349 LOC) split into 6 colocated components:
TabNavigation, StatCard, FilterBar, ReportCard, WhistleblowerCreateModal,
CaseDetailPanel. All under the 300 LOC soft target.
Drive-by fix: the earlier fc6a330 split of compliance-scope-types.ts
dropped several helper exports that downstream consumers still import
(lib/sdk/index.ts, compliance-scope-engine.ts, obligations page,
compliance-scope page, constraint-enforcer, drafting-engine validate).
Restored them in the appropriate domain modules:
- core-levels.ts: maxDepthLevel, getDepthLevelNumeric, depthLevelFromNumeric
- state.ts: createEmptyScopeState
- decisions.ts: createEmptyScopeDecision + ApplicableRegulation,
RegulationObligation, RegulationAssessmentResult, SupervisoryAuthorityInfo
Verification: next build clean (142 pages generated), /sdk/whistleblower
still builds at ~11.5 kB.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
192 lines
8.4 KiB
TypeScript
192 lines
8.4 KiB
TypeScript
'use client'
|
|
|
|
import React from 'react'
|
|
import {
|
|
WhistleblowerReport,
|
|
ReportPriority,
|
|
REPORT_CATEGORY_INFO,
|
|
REPORT_STATUS_INFO,
|
|
isAcknowledgmentOverdue,
|
|
isFeedbackOverdue,
|
|
getDaysUntilAcknowledgment,
|
|
getDaysUntilFeedback
|
|
} from '@/lib/sdk/whistleblower/types'
|
|
|
|
export function ReportCard({ report, onClick }: { report: WhistleblowerReport; onClick?: () => void }) {
|
|
const categoryInfo = REPORT_CATEGORY_INFO[report.category]
|
|
const statusInfo = REPORT_STATUS_INFO[report.status]
|
|
const isClosed = report.status === 'closed' || report.status === 'rejected'
|
|
|
|
const ackOverdue = isAcknowledgmentOverdue(report)
|
|
const fbOverdue = isFeedbackOverdue(report)
|
|
const daysAck = getDaysUntilAcknowledgment(report)
|
|
const daysFb = getDaysUntilFeedback(report)
|
|
|
|
const completedMeasures = report.measures.filter(m => m.status === 'completed').length
|
|
const totalMeasures = report.measures.length
|
|
|
|
const priorityLabels: Record<ReportPriority, string> = {
|
|
low: 'Niedrig',
|
|
normal: 'Normal',
|
|
high: 'Hoch',
|
|
critical: 'Kritisch'
|
|
}
|
|
|
|
return (
|
|
<div
|
|
onClick={onClick}
|
|
className={`
|
|
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
|
|
${ackOverdue || fbOverdue ? 'border-red-300 hover:border-red-400' :
|
|
report.priority === 'critical' ? 'border-orange-300 hover:border-orange-400' :
|
|
isClosed ? 'border-green-200 hover:border-green-300' :
|
|
'border-gray-200 hover:border-purple-300'
|
|
}
|
|
`}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1 min-w-0">
|
|
{/* Header Badges */}
|
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
|
<span className="text-xs text-gray-500 font-mono">
|
|
{report.referenceNumber}
|
|
</span>
|
|
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
|
|
{categoryInfo.label}
|
|
</span>
|
|
<span className={`px-2 py-1 text-xs rounded-full ${statusInfo.bgColor} ${statusInfo.color}`}>
|
|
{statusInfo.label}
|
|
</span>
|
|
{report.isAnonymous && (
|
|
<span className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded-full flex items-center gap-1">
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
</svg>
|
|
Anonym
|
|
</span>
|
|
)}
|
|
{report.priority === 'critical' && (
|
|
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded-full flex items-center gap-1">
|
|
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<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>
|
|
Kritisch
|
|
</span>
|
|
)}
|
|
{report.priority === 'high' && (
|
|
<span className="px-2 py-1 text-xs bg-orange-100 text-orange-700 rounded-full">
|
|
Hoch
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Title */}
|
|
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
|
{report.title}
|
|
</h3>
|
|
|
|
{/* Description Preview */}
|
|
{report.description && (
|
|
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
|
|
{report.description}
|
|
</p>
|
|
)}
|
|
|
|
{/* Deadline Info */}
|
|
{!isClosed && (
|
|
<div className="flex items-center gap-4 mt-3 text-xs">
|
|
{report.status === 'new' && (
|
|
<span className={`flex items-center gap-1 ${ackOverdue ? 'text-red-600 font-medium' : daysAck <= 2 ? 'text-orange-600' : 'text-gray-500'}`}>
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
{ackOverdue
|
|
? `Bestaetigung ${Math.abs(daysAck)} Tage ueberfaellig`
|
|
: `Bestaetigung in ${daysAck} Tagen`
|
|
}
|
|
</span>
|
|
)}
|
|
<span className={`flex items-center gap-1 ${fbOverdue ? 'text-red-600 font-medium' : daysFb <= 14 ? 'text-orange-600' : 'text-gray-500'}`}>
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
</svg>
|
|
{fbOverdue
|
|
? `Rueckmeldung ${Math.abs(daysFb)} Tage ueberfaellig`
|
|
: `Rueckmeldung in ${daysFb} Tagen`
|
|
}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right Side - Date & Priority */}
|
|
<div className={`text-right ml-4 ${
|
|
ackOverdue || fbOverdue ? 'text-red-600' :
|
|
report.priority === 'critical' ? 'text-orange-600' :
|
|
'text-gray-500'
|
|
}`}>
|
|
<div className="text-sm font-medium">
|
|
{isClosed
|
|
? statusInfo.label
|
|
: ackOverdue
|
|
? 'Ueberfaellig'
|
|
: priorityLabels[report.priority]
|
|
}
|
|
</div>
|
|
<div className="text-xs mt-0.5">
|
|
{new Date(report.receivedAt).toLocaleDateString('de-DE')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="text-sm text-gray-500">
|
|
{report.assignedTo
|
|
? `Zugewiesen: ${report.assignedTo}`
|
|
: 'Nicht zugewiesen'
|
|
}
|
|
</div>
|
|
{report.attachments.length > 0 && (
|
|
<span className="flex items-center gap-1 text-xs text-gray-400">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
|
</svg>
|
|
{report.attachments.length} Anhang{report.attachments.length !== 1 ? 'e' : ''}
|
|
</span>
|
|
)}
|
|
{totalMeasures > 0 && (
|
|
<span className={`flex items-center gap-1 text-xs ${completedMeasures === totalMeasures ? 'text-green-600' : 'text-yellow-600'}`}>
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
|
</svg>
|
|
{completedMeasures}/{totalMeasures} Massnahmen
|
|
</span>
|
|
)}
|
|
{report.messages.length > 0 && (
|
|
<span className="flex items-center gap-1 text-xs text-gray-400">
|
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
</svg>
|
|
{report.messages.length} Nachricht{report.messages.length !== 1 ? 'en' : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{!isClosed && (
|
|
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
|
Bearbeiten
|
|
</span>
|
|
)}
|
|
{isClosed && (
|
|
<span className="px-3 py-1 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
|
Details
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|