'use client'
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
WhistleblowerReport,
WhistleblowerStatistics,
ReportCategory,
ReportStatus,
ReportPriority,
REPORT_CATEGORY_INFO,
REPORT_STATUS_INFO,
isAcknowledgmentOverdue,
isFeedbackOverdue,
getDaysUntilAcknowledgment,
getDaysUntilFeedback
} from '@/lib/sdk/whistleblower/types'
import { fetchSDKWhistleblowerList } from '@/lib/sdk/whistleblower/api'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'new_reports' | 'investigation' | 'closed' | 'settings'
interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
{tabs.map(tab => (
onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
{tab.count}
)}
))}
)
}
function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600'
}
return (
{label}
{value}
{trend && (
= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
)}
{icon && (
{icon}
)}
)
}
function FilterBar({
selectedCategory,
selectedStatus,
selectedPriority,
onCategoryChange,
onStatusChange,
onPriorityChange,
onClear
}: {
selectedCategory: ReportCategory | 'all'
selectedStatus: ReportStatus | 'all'
selectedPriority: ReportPriority | 'all'
onCategoryChange: (category: ReportCategory | 'all') => void
onStatusChange: (status: ReportStatus | 'all') => void
onPriorityChange: (priority: ReportPriority | 'all') => void
onClear: () => void
}) {
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
return (
Filter:
{/* Category Filter */}
onCategoryChange(e.target.value as ReportCategory | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
Alle Kategorien
{Object.entries(REPORT_CATEGORY_INFO).map(([key, info]) => (
{info.label}
))}
{/* Status Filter */}
onStatusChange(e.target.value as ReportStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
Alle Status
{Object.entries(REPORT_STATUS_INFO).map(([status, info]) => (
{info.label}
))}
{/* Priority Filter */}
onPriorityChange(e.target.value as ReportPriority | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
Alle Prioritaeten
Kritisch
Hoch
Normal
Niedrig
{/* Clear Filters */}
{hasFilters && (
Filter zuruecksetzen
)}
)
}
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 = {
low: 'Niedrig',
normal: 'Normal',
high: 'Hoch',
critical: 'Kritisch'
}
return (
{/* Header Badges */}
{report.referenceNumber}
{categoryInfo.label}
{statusInfo.label}
{report.isAnonymous && (
Anonym
)}
{report.priority === 'critical' && (
Kritisch
)}
{report.priority === 'high' && (
Hoch
)}
{/* Title */}
{report.title}
{/* Description Preview */}
{report.description && (
{report.description}
)}
{/* Deadline Info */}
{!isClosed && (
{report.status === 'new' && (
{ackOverdue
? `Bestaetigung ${Math.abs(daysAck)} Tage ueberfaellig`
: `Bestaetigung in ${daysAck} Tagen`
}
)}
{fbOverdue
? `Rueckmeldung ${Math.abs(daysFb)} Tage ueberfaellig`
: `Rueckmeldung in ${daysFb} Tagen`
}
)}
{/* Right Side - Date & Priority */}
{isClosed
? statusInfo.label
: ackOverdue
? 'Ueberfaellig'
: priorityLabels[report.priority]
}
{new Date(report.receivedAt).toLocaleDateString('de-DE')}
{/* Footer */}
{report.assignedTo
? `Zugewiesen: ${report.assignedTo}`
: 'Nicht zugewiesen'
}
{report.attachments.length > 0 && (
{report.attachments.length} Anhang{report.attachments.length !== 1 ? 'e' : ''}
)}
{totalMeasures > 0 && (
{completedMeasures}/{totalMeasures} Massnahmen
)}
{report.messages.length > 0 && (
{report.messages.length} Nachricht{report.messages.length !== 1 ? 'en' : ''}
)}
{!isClosed && (
Bearbeiten
)}
{isClosed && (
Details
)}
)
}
// =============================================================================
// WHISTLEBLOWER CREATE MODAL
// =============================================================================
function WhistleblowerCreateModal({
onClose,
onSuccess
}: {
onClose: () => void
onSuccess: () => void
}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [category, setCategory] = useState('corruption')
const [priority, setPriority] = useState('normal')
const [isAnonymous, setIsAnonymous] = useState(true)
const [reporterName, setReporterName] = useState('')
const [reporterEmail, setReporterEmail] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !description.trim()) return
setIsSaving(true)
setError(null)
try {
const body: Record = {
title: title.trim(),
description: description.trim(),
category,
priority,
isAnonymous,
status: 'new'
}
if (!isAnonymous) {
body.reporterName = reporterName.trim()
body.reporterEmail = reporterEmail.trim()
}
const res = await fetch('/api/sdk/v1/whistleblower/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
throw new Error(data?.detail || data?.message || `Fehler ${res.status}`)
}
onSuccess()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setIsSaving(false)
}
}
return (
{/* Backdrop */}
{/* Modal */}
)
}
// =============================================================================
// CASE DETAIL PANEL
// =============================================================================
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(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> = {
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 */}
{/* Drawer */}
{/* Header */}
{report.referenceNumber}
{report.title}
{actionError && (
{actionError}
)}
{/* Badges */}
{categoryInfo.label}
{statusInfo.label}
{report.isAnonymous && (
Anonym
)}
{report.priority === 'critical' ? 'Kritisch' :
report.priority === 'high' ? 'Hoch' :
report.priority === 'normal' ? 'Normal' : 'Niedrig'}
{/* Description */}
Beschreibung
{report.description}
{/* Details */}
Eingegangen am
{new Date(report.receivedAt).toLocaleDateString('de-DE')}
Zugewiesen an
{report.assignedTo || '—'}
{/* Status Transitions */}
{transitions.length > 0 && (
Status aendern
{transitions.map((t) => (
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}
))}
)}
{/* Assign Officer */}
{/* Comment Section */}
Kommentar senden
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..."
/>
{isSendingComment ? 'Wird gesendet...' : 'Kommentar senden'}
{/* Message History */}
{report.messages.length > 0 && (
Nachrichten ({report.messages.length})
{report.messages.map((msg, idx) => (
{msg.senderRole}
{new Date(msg.sentAt).toLocaleDateString('de-DE')}
{msg.message}
))}
)}
{/* Delete */}
{isDeleting ? 'Löschen...' : 'Löschen'}
>
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function WhistleblowerPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState('overview')
const [reports, setReports] = useState([])
const [statistics, setStatistics] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [selectedReport, setSelectedReport] = useState(null)
// Filters
const [selectedCategory, setSelectedCategory] = useState('all')
const [selectedStatus, setSelectedStatus] = useState('all')
const [selectedPriority, setSelectedPriority] = useState('all')
// Load data from SDK backend
const loadData = useCallback(async () => {
setIsLoading(true)
try {
const { reports: wbReports, statistics: wbStats } = await fetchSDKWhistleblowerList()
setReports(wbReports)
setStatistics(wbStats)
} catch (error) {
console.error('Failed to load Whistleblower data:', error)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
// Locally computed overdue counts (always fresh)
const overdueCounts = useMemo(() => {
const overdueAck = reports.filter(r => isAcknowledgmentOverdue(r)).length
const overdueFb = reports.filter(r => isFeedbackOverdue(r)).length
return { overdueAck, overdueFb }
}, [reports])
// Calculate tab counts
const tabCounts = useMemo(() => {
const investigationStatuses: ReportStatus[] = ['acknowledged', 'under_review', 'investigation', 'measures_taken']
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
return {
new_reports: reports.filter(r => r.status === 'new').length,
investigation: reports.filter(r => investigationStatuses.includes(r.status)).length,
closed: reports.filter(r => closedStatuses.includes(r.status)).length
}
}, [reports])
// Filter reports based on active tab and filters
const filteredReports = useMemo(() => {
let filtered = [...reports]
// Tab-based filtering
const investigationStatuses: ReportStatus[] = ['acknowledged', 'under_review', 'investigation', 'measures_taken']
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
if (activeTab === 'new_reports') {
filtered = filtered.filter(r => r.status === 'new')
} else if (activeTab === 'investigation') {
filtered = filtered.filter(r => investigationStatuses.includes(r.status))
} else if (activeTab === 'closed') {
filtered = filtered.filter(r => closedStatuses.includes(r.status))
}
// Category filter
if (selectedCategory !== 'all') {
filtered = filtered.filter(r => r.category === selectedCategory)
}
// Status filter
if (selectedStatus !== 'all') {
filtered = filtered.filter(r => r.status === selectedStatus)
}
// Priority filter
if (selectedPriority !== 'all') {
filtered = filtered.filter(r => r.priority === selectedPriority)
}
// Sort: overdue first, then by priority, then by date
return filtered.sort((a, b) => {
const closedStatuses: ReportStatus[] = ['closed', 'rejected']
const getUrgency = (r: WhistleblowerReport) => {
if (closedStatuses.includes(r.status)) return 1000
const ackOd = isAcknowledgmentOverdue(r)
const fbOd = isFeedbackOverdue(r)
if (ackOd || fbOd) return -100
const priorityScore = { critical: 0, high: 1, normal: 2, low: 3 }
return priorityScore[r.priority] ?? 2
}
const urgencyDiff = getUrgency(a) - getUrgency(b)
if (urgencyDiff !== 0) return urgencyDiff
return new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
})
}, [reports, activeTab, selectedCategory, selectedStatus, selectedPriority])
const tabs: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'new_reports', label: 'Neue Meldungen', count: tabCounts.new_reports, countColor: 'bg-blue-100 text-blue-600' },
{ id: 'investigation', label: 'In Untersuchung', count: tabCounts.investigation, countColor: 'bg-yellow-100 text-yellow-600' },
{ id: 'closed', label: 'Abgeschlossen', count: tabCounts.closed, countColor: 'bg-green-100 text-green-600' },
{ id: 'settings', label: 'Einstellungen' }
]
const stepInfo = STEP_EXPLANATIONS['whistleblower']
const clearFilters = () => {
setSelectedCategory('all')
setSelectedStatus('all')
setSelectedPriority('all')
}
return (
{/* Step Header */}
setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Meldung erfassen
{/* Tab Navigation */}
{/* Loading State */}
{isLoading ? (
) : activeTab === 'settings' ? (
/* Settings Tab */
Einstellungen
Hinweisgebersystem-Einstellungen, Meldekanal-Konfiguration, Ombudsperson-Verwaltung
und E-Mail-Vorlagen werden in einer spaeteren Version verfuegbar sein.
) : (
<>
{/* Statistics (Overview Tab) */}
{activeTab === 'overview' && statistics && (
0 ? 'red' : 'green'}
/>
)}
{/* Overdue Alert for Acknowledgment Deadline (7 days HinSchG) */}
{(overdueCounts.overdueAck > 0 || overdueCounts.overdueFb > 0) && (activeTab === 'overview' || activeTab === 'new_reports' || activeTab === 'investigation') && (
Achtung: Gesetzliche Fristen ueberschritten
{overdueCounts.overdueAck > 0 && (
{overdueCounts.overdueAck} Meldung(en) ohne Eingangsbestaetigung (mehr als 7 Tage, HinSchG ss 17 Abs. 1).
)}
{overdueCounts.overdueFb > 0 && (
{overdueCounts.overdueFb} Meldung(en) ohne Rueckmeldung (mehr als 3 Monate, HinSchG ss 17 Abs. 2).
)}
Handeln Sie umgehend, um Bussgelder und Haftungsrisiken zu vermeiden.
{
if (overdueCounts.overdueAck > 0) {
setActiveTab('new_reports')
} else {
setActiveTab('investigation')
}
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
)}
{/* Info Box about HinSchG (Overview Tab) */}
{activeTab === 'overview' && (
{/* Gesetzliche Grundlage */}
Gesetzliche Grundlage: Hinweisgeberschutzgesetz (HinSchG)
Das HinSchG setzt die EU-Whistleblowing-Richtlinie (2019/1937) in deutsches Recht um
und ist seit dem 2. Juli 2023 in Kraft. Seit dem 17. Dezember 2023 gilt
die Pflicht zur Einrichtung einer internen Meldestelle auch fuer Unternehmen ab 50 Beschaeftigten (ss 12 HinSchG).
{/* Fristen & Pflichten */}
Eingangsbestaetigung an den Hinweisgeber innerhalb von 7 Tagen nach Meldungseingang (ss 17 Abs. 1 S. 2 HinSchG).
Rueckmeldung ueber ergriffene Folgemaßnahmen innerhalb von 3 Monaten nach Eingangsbestaetigung (ss 17 Abs. 2 HinSchG).
Dokumentation der Meldungen und Folgemaßnahmen ist 3 Jahre nach Abschluss aufzubewahren (ss 11 Abs. 5 HinSchG).
{/* Sachlicher Anwendungsbereich & Schutz */}
Sachlicher Anwendungsbereich (ss 2 HinSchG)
Verstoesse gegen Strafvorschriften (StGB, Nebenstrafrecht)
Verstoesse gegen Datenschutzrecht (DSGVO, BDSG)
Geldwaesche und Terrorismusfinanzierung (GwG)
Produktsicherheit und Verbraucherschutz
Umweltschutz und Lebensmittelsicherheit
Arbeitsschutz und Arbeitnehmerrechte
Wettbewerbs- und Kartellrecht
Steuer- und Abgabenrecht (bei Unternehmen)
Schutz des Hinweisgebers (ss 36–37 HinSchG)
Repressalienverbot: Jede Benachteiligung ist untersagt (ss 36)
Beweislastumkehr: Arbeitgeber muss beweisen, dass Maßnahmen nicht mit Meldung zusammenhaengen
Schadensersatz: Bei Verstoessen gegen Repressalienverbot (ss 37)
Vertraulichkeit: Identitaet darf nur bei Zustimmung oder gesetzlicher Pflicht offengelegt werden (ss 8)
Bussgelder: Bis zu 50.000 EUR bei Verstoessen gegen die Einrichtungspflicht (ss 40)
)}
{/* Filters */}
{/* Report List */}
{filteredReports.map(report => (
setSelectedReport(report)}
/>
))}
{/* Empty State */}
{filteredReports.length === 0 && (
Keine Meldungen gefunden
{selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all'
? 'Passen Sie die Filter an oder setzen Sie sie zurueck.'
: 'Es sind noch keine Meldungen im Hinweisgebersystem vorhanden. Meldungen werden ueber das oeffentliche Meldeformular eingereicht.'
}
{(selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all') && (
Filter zuruecksetzen
)}
)}
>
)}
{/* Modals */}
{showCreateModal && (
setShowCreateModal(false)}
onSuccess={() => { setShowCreateModal(false); loadData() }}
/>
)}
{selectedReport && (
setSelectedReport(null)}
onUpdated={() => { setSelectedReport(null); loadData() }}
onDeleted={() => { setSelectedReport(null); loadData() }}
/>
)}
)
}