Files
Sharang Parnerkar 1f45d6cca8 refactor(admin): split whistleblower page.tsx + restore scope helpers
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>
2026-04-11 22:50:25 +02:00

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