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>
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState } from 'react'
|
|
import {
|
|
WhistleblowerReport,
|
|
ReportStatus,
|
|
REPORT_CATEGORY_INFO,
|
|
REPORT_STATUS_INFO
|
|
} from '@/lib/sdk/whistleblower/types'
|
|
|
|
export function CaseDetailPanel({
|
|
report,
|
|
onClose,
|
|
onUpdated,
|
|
onDeleted,
|
|
}: {
|
|
report: WhistleblowerReport
|
|
onClose: () => void
|
|
onUpdated: () => void
|
|
onDeleted?: () => void
|
|
}) {
|
|
const [officerName, setOfficerName] = useState(report.assignedTo || '')
|
|
const [commentText, setCommentText] = useState('')
|
|
const [isSavingOfficer, setIsSavingOfficer] = useState(false)
|
|
const [isSavingStatus, setIsSavingStatus] = useState(false)
|
|
const [isSendingComment, setIsSendingComment] = useState(false)
|
|
const [isDeleting, setIsDeleting] = useState(false)
|
|
const [actionError, setActionError] = useState<string | null>(null)
|
|
|
|
const handleDeleteReport = async () => {
|
|
if (!window.confirm(`Meldung "${report.title}" wirklich löschen?`)) return
|
|
setIsDeleting(true)
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/whistleblower/reports/${report.id}`, { method: 'DELETE' })
|
|
if (!res.ok) throw new Error(`Fehler: ${res.status}`)
|
|
onDeleted ? onDeleted() : onClose()
|
|
} catch (err: unknown) {
|
|
setActionError(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.')
|
|
} finally {
|
|
setIsDeleting(false)
|
|
}
|
|
}
|
|
|
|
const categoryInfo = REPORT_CATEGORY_INFO[report.category]
|
|
const statusInfo = REPORT_STATUS_INFO[report.status]
|
|
|
|
const statusTransitions: Partial<Record<ReportStatus, { label: string; next: string }[]>> = {
|
|
new: [{ label: 'Bestaetigen', next: 'acknowledged' }],
|
|
acknowledged: [{ label: 'Pruefung starten', next: 'under_review' }],
|
|
under_review: [{ label: 'Untersuchung starten', next: 'investigation' }],
|
|
investigation: [{ label: 'Massnahmen eingeleitet', next: 'measures_taken' }],
|
|
measures_taken: [{ label: 'Abschliessen', next: 'closed' }]
|
|
}
|
|
|
|
const transitions = statusTransitions[report.status] || []
|
|
|
|
const handleStatusChange = async (newStatus: string) => {
|
|
setIsSavingStatus(true)
|
|
setActionError(null)
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/whistleblower/reports/${report.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: newStatus })
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error(data?.detail || data?.message || `Fehler ${res.status}`)
|
|
}
|
|
onUpdated()
|
|
} catch (err: unknown) {
|
|
setActionError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
|
} finally {
|
|
setIsSavingStatus(false)
|
|
}
|
|
}
|
|
|
|
const handleSaveOfficer = async () => {
|
|
setIsSavingOfficer(true)
|
|
setActionError(null)
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/whistleblower/reports/${report.id}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ assignedTo: officerName })
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error(data?.detail || data?.message || `Fehler ${res.status}`)
|
|
}
|
|
onUpdated()
|
|
} catch (err: unknown) {
|
|
setActionError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
|
} finally {
|
|
setIsSavingOfficer(false)
|
|
}
|
|
}
|
|
|
|
const handleSendComment = async () => {
|
|
if (!commentText.trim()) return
|
|
setIsSendingComment(true)
|
|
setActionError(null)
|
|
try {
|
|
const res = await fetch(`/api/sdk/v1/whistleblower/reports/${report.id}/messages`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ senderRole: 'ombudsperson', message: commentText.trim() })
|
|
})
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}))
|
|
throw new Error(data?.detail || data?.message || `Fehler ${res.status}`)
|
|
}
|
|
setCommentText('')
|
|
onUpdated()
|
|
} catch (err: unknown) {
|
|
setActionError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
|
} finally {
|
|
setIsSendingComment(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div
|
|
className="fixed inset-0 z-40 bg-black/30"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Drawer */}
|
|
<div className="fixed right-0 top-0 bottom-0 z-50 w-[600px] bg-white shadow-2xl overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="sticky top-0 bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs text-gray-500 font-mono">{report.referenceNumber}</p>
|
|
<h2 className="text-lg font-semibold text-gray-900 mt-0.5">{report.title}</h2>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg 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>
|
|
|
|
<div className="p-6 space-y-6">
|
|
{actionError && (
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
|
{actionError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Badges */}
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<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">
|
|
Anonym
|
|
</span>
|
|
)}
|
|
<span className={`px-2 py-1 text-xs rounded-full ${
|
|
report.priority === 'critical' ? 'bg-red-100 text-red-700' :
|
|
report.priority === 'high' ? 'bg-orange-100 text-orange-700' :
|
|
'bg-gray-100 text-gray-600'
|
|
}`}>
|
|
{report.priority === 'critical' ? 'Kritisch' :
|
|
report.priority === 'high' ? 'Hoch' :
|
|
report.priority === 'normal' ? 'Normal' : 'Niedrig'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-700 mb-2">Beschreibung</h3>
|
|
<p className="text-sm text-gray-600 whitespace-pre-wrap">{report.description}</p>
|
|
</div>
|
|
|
|
{/* Details */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<p className="text-xs text-gray-500">Eingegangen am</p>
|
|
<p className="text-sm font-medium text-gray-900">
|
|
{new Date(report.receivedAt).toLocaleDateString('de-DE')}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs text-gray-500">Zugewiesen an</p>
|
|
<p className="text-sm font-medium text-gray-900">
|
|
{report.assignedTo || '—'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status Transitions */}
|
|
{transitions.length > 0 && (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Status aendern</h3>
|
|
<div className="flex flex-wrap gap-2">
|
|
{transitions.map((t) => (
|
|
<button
|
|
key={t.next}
|
|
onClick={() => handleStatusChange(t.next)}
|
|
disabled={isSavingStatus}
|
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{isSavingStatus ? 'Wird gespeichert...' : t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Assign Officer */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-700 mb-2">Zuweisen an:</h3>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={officerName}
|
|
onChange={(e) => setOfficerName(e.target.value)}
|
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm"
|
|
placeholder="Name der zustaendigen Person"
|
|
/>
|
|
<button
|
|
onClick={handleSaveOfficer}
|
|
disabled={isSavingOfficer}
|
|
className="px-4 py-2 text-sm bg-gray-800 text-white rounded-lg hover:bg-gray-900 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{isSavingOfficer ? 'Speichern...' : 'Speichern'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Comment Section */}
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-700 mb-2">Kommentar senden</h3>
|
|
<textarea
|
|
value={commentText}
|
|
onChange={(e) => setCommentText(e.target.value)}
|
|
rows={3}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 text-sm resize-none"
|
|
placeholder="Kommentar eingeben..."
|
|
/>
|
|
<div className="mt-2 flex justify-end">
|
|
<button
|
|
onClick={handleSendComment}
|
|
disabled={isSendingComment || !commentText.trim()}
|
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
>
|
|
{isSendingComment ? 'Wird gesendet...' : 'Kommentar senden'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Message History */}
|
|
{report.messages.length > 0 && (
|
|
<div>
|
|
<h3 className="text-sm font-medium text-gray-700 mb-3">Nachrichten ({report.messages.length})</h3>
|
|
<div className="space-y-3">
|
|
{report.messages.map((msg, idx) => (
|
|
<div key={idx} className="p-3 bg-gray-50 rounded-lg text-sm">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="font-medium text-gray-700">{msg.senderRole}</span>
|
|
<span className="text-xs text-gray-400">
|
|
{new Date(msg.sentAt).toLocaleDateString('de-DE')}
|
|
</span>
|
|
</div>
|
|
<p className="text-gray-600">{msg.message}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Delete */}
|
|
<div className="pt-2 border-t border-gray-100">
|
|
<button
|
|
onClick={handleDeleteReport}
|
|
disabled={isDeleting}
|
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm transition-colors disabled:opacity-50"
|
|
>
|
|
{isDeleting ? 'Löschen...' : 'Löschen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|