# Conflicts: # admin-compliance/lib/sdk/types.ts # admin-compliance/lib/sdk/vendor-compliance/types.ts
414 lines
20 KiB
TypeScript
414 lines
20 KiB
TypeScript
'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,
|
||
isAcknowledgmentOverdue,
|
||
isFeedbackOverdue,
|
||
} from '@/lib/sdk/whistleblower/types'
|
||
import { fetchSDKWhistleblowerList } from '@/lib/sdk/whistleblower/api'
|
||
import { TabNavigation, type Tab, type TabId } from './_components/TabNavigation'
|
||
import { StatCard } from './_components/StatCard'
|
||
import { FilterBar } from './_components/FilterBar'
|
||
import { ReportCard } from './_components/ReportCard'
|
||
import { WhistleblowerCreateModal } from './_components/WhistleblowerCreateModal'
|
||
import { CaseDetailPanel } from './_components/CaseDetailPanel'
|
||
|
||
// =============================================================================
|
||
// MAIN PAGE
|
||
// =============================================================================
|
||
|
||
export default function WhistleblowerPage() {
|
||
const { state } = useSDK()
|
||
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
||
const [reports, setReports] = useState<WhistleblowerReport[]>([])
|
||
const [statistics, setStatistics] = useState<WhistleblowerStatistics | null>(null)
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||
const [selectedReport, setSelectedReport] = useState<WhistleblowerReport | null>(null)
|
||
|
||
// Filters
|
||
const [selectedCategory, setSelectedCategory] = useState<ReportCategory | 'all'>('all')
|
||
const [selectedStatus, setSelectedStatus] = useState<ReportStatus | 'all'>('all')
|
||
const [selectedPriority, setSelectedPriority] = useState<ReportPriority | 'all'>('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 (
|
||
<div className="space-y-6">
|
||
{/* Step Header */}
|
||
<StepHeader
|
||
stepId="whistleblower"
|
||
title={stepInfo.title}
|
||
description={stepInfo.description}
|
||
explanation={stepInfo.explanation}
|
||
tips={stepInfo.tips}
|
||
>
|
||
<button
|
||
onClick={() => 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"
|
||
>
|
||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||
</svg>
|
||
Meldung erfassen
|
||
</button>
|
||
</StepHeader>
|
||
|
||
{/* Tab Navigation */}
|
||
<TabNavigation
|
||
tabs={tabs}
|
||
activeTab={activeTab}
|
||
onTabChange={setActiveTab}
|
||
/>
|
||
|
||
{/* Loading State */}
|
||
{isLoading ? (
|
||
<div className="flex items-center justify-center py-12">
|
||
<svg className="animate-spin w-8 h-8 text-purple-600" fill="none" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||
</svg>
|
||
</div>
|
||
) : activeTab === 'settings' ? (
|
||
/* Settings Tab */
|
||
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
|
||
<p className="mt-2 text-gray-500">
|
||
Hinweisgebersystem-Einstellungen, Meldekanal-Konfiguration, Ombudsperson-Verwaltung
|
||
und E-Mail-Vorlagen werden in einer spaeteren Version verfuegbar sein.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Statistics (Overview Tab) */}
|
||
{activeTab === 'overview' && statistics && (
|
||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||
<StatCard
|
||
label="Gesamt Meldungen"
|
||
value={statistics.totalReports}
|
||
color="gray"
|
||
/>
|
||
<StatCard
|
||
label="Neue Meldungen"
|
||
value={statistics.newReports}
|
||
color="blue"
|
||
/>
|
||
<StatCard
|
||
label="In Untersuchung"
|
||
value={statistics.underReview}
|
||
color="yellow"
|
||
/>
|
||
<StatCard
|
||
label="Ueberfaellige Bestaetigung"
|
||
value={overdueCounts.overdueAck}
|
||
color={overdueCounts.overdueAck > 0 ? 'red' : 'green'}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Overdue Alert for Acknowledgment Deadline (7 days HinSchG) */}
|
||
{(overdueCounts.overdueAck > 0 || overdueCounts.overdueFb > 0) && (activeTab === 'overview' || activeTab === 'new_reports' || activeTab === 'investigation') && (
|
||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 flex items-center gap-4">
|
||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||
<svg className="w-5 h-5 text-red-600" 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>
|
||
</div>
|
||
<div className="flex-1">
|
||
<h4 className="font-medium text-red-800">
|
||
Achtung: Gesetzliche Fristen ueberschritten
|
||
</h4>
|
||
<p className="text-sm text-red-600 mt-0.5">
|
||
{overdueCounts.overdueAck > 0 && (
|
||
<span>{overdueCounts.overdueAck} Meldung(en) ohne Eingangsbestaetigung (mehr als 7 Tage, HinSchG ss 17 Abs. 1). </span>
|
||
)}
|
||
{overdueCounts.overdueFb > 0 && (
|
||
<span>{overdueCounts.overdueFb} Meldung(en) ohne Rueckmeldung (mehr als 3 Monate, HinSchG ss 17 Abs. 2). </span>
|
||
)}
|
||
Handeln Sie umgehend, um Bussgelder und Haftungsrisiken zu vermeiden.
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => {
|
||
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
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Info Box about HinSchG (Overview Tab) */}
|
||
{activeTab === 'overview' && (
|
||
<div className="space-y-4">
|
||
{/* Gesetzliche Grundlage */}
|
||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-5">
|
||
<div className="flex items-start gap-3">
|
||
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<div>
|
||
<h4 className="font-medium text-blue-800">Gesetzliche Grundlage: Hinweisgeberschutzgesetz (HinSchG)</h4>
|
||
<p className="text-sm text-blue-600 mt-1">
|
||
Das HinSchG setzt die <strong>EU-Whistleblowing-Richtlinie (2019/1937)</strong> in deutsches Recht um
|
||
und ist seit dem <strong>2. Juli 2023</strong> in Kraft. Seit dem <strong>17. Dezember 2023</strong> gilt
|
||
die Pflicht zur Einrichtung einer internen Meldestelle auch fuer Unternehmen ab 50 Beschaeftigten (ss 12 HinSchG).
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Fristen & Pflichten */}
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<svg className="w-4 h-4 text-orange-500" 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>
|
||
<h5 className="text-sm font-semibold text-gray-900">7-Tage-Frist</h5>
|
||
</div>
|
||
<p className="text-xs text-gray-600">
|
||
Eingangsbestaetigung an den Hinweisgeber innerhalb von 7 Tagen nach Meldungseingang (ss 17 Abs. 1 S. 2 HinSchG).
|
||
</p>
|
||
</div>
|
||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<svg className="w-4 h-4 text-purple-500" 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>
|
||
<h5 className="text-sm font-semibold text-gray-900">3-Monate-Frist</h5>
|
||
</div>
|
||
<p className="text-xs text-gray-600">
|
||
Rueckmeldung ueber ergriffene Folgemaßnahmen innerhalb von 3 Monaten nach Eingangsbestaetigung (ss 17 Abs. 2 HinSchG).
|
||
</p>
|
||
</div>
|
||
<div className="bg-white border border-gray-200 rounded-xl p-4">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<svg className="w-4 h-4 text-red-500" 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>
|
||
<h5 className="text-sm font-semibold text-gray-900">3 Jahre Aufbewahrung</h5>
|
||
</div>
|
||
<p className="text-xs text-gray-600">
|
||
Dokumentation der Meldungen und Folgemaßnahmen ist 3 Jahre nach Abschluss aufzubewahren (ss 11 Abs. 5 HinSchG).
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sachlicher Anwendungsbereich & Schutz */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4">
|
||
<h5 className="text-sm font-semibold text-amber-800 mb-2">Sachlicher Anwendungsbereich (ss 2 HinSchG)</h5>
|
||
<ul className="text-xs text-amber-700 space-y-1">
|
||
<li>Verstoesse gegen Strafvorschriften (StGB, Nebenstrafrecht)</li>
|
||
<li>Verstoesse gegen Datenschutzrecht (DSGVO, BDSG)</li>
|
||
<li>Geldwaesche und Terrorismusfinanzierung (GwG)</li>
|
||
<li>Produktsicherheit und Verbraucherschutz</li>
|
||
<li>Umweltschutz und Lebensmittelsicherheit</li>
|
||
<li>Arbeitsschutz und Arbeitnehmerrechte</li>
|
||
<li>Wettbewerbs- und Kartellrecht</li>
|
||
<li>Steuer- und Abgabenrecht (bei Unternehmen)</li>
|
||
</ul>
|
||
</div>
|
||
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
||
<h5 className="text-sm font-semibold text-green-800 mb-2">Schutz des Hinweisgebers (ss 36–37 HinSchG)</h5>
|
||
<ul className="text-xs text-green-700 space-y-1">
|
||
<li><strong>Repressalienverbot:</strong> Jede Benachteiligung ist untersagt (ss 36)</li>
|
||
<li><strong>Beweislastumkehr:</strong> Arbeitgeber muss beweisen, dass Maßnahmen nicht mit Meldung zusammenhaengen</li>
|
||
<li><strong>Schadensersatz:</strong> Bei Verstoessen gegen Repressalienverbot (ss 37)</li>
|
||
<li><strong>Vertraulichkeit:</strong> Identitaet darf nur bei Zustimmung oder gesetzlicher Pflicht offengelegt werden (ss 8)</li>
|
||
<li><strong>Bussgelder:</strong> Bis zu 50.000 EUR bei Verstoessen gegen die Einrichtungspflicht (ss 40)</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Filters */}
|
||
<FilterBar
|
||
selectedCategory={selectedCategory}
|
||
selectedStatus={selectedStatus}
|
||
selectedPriority={selectedPriority}
|
||
onCategoryChange={setSelectedCategory}
|
||
onStatusChange={setSelectedStatus}
|
||
onPriorityChange={setSelectedPriority}
|
||
onClear={clearFilters}
|
||
/>
|
||
|
||
{/* Report List */}
|
||
<div className="space-y-4">
|
||
{filteredReports.map(report => (
|
||
<ReportCard
|
||
key={report.id}
|
||
report={report}
|
||
onClick={() => setSelectedReport(report)}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* Empty State */}
|
||
{filteredReports.length === 0 && (
|
||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
|
||
</svg>
|
||
</div>
|
||
<h3 className="text-lg font-semibold text-gray-900">Keine Meldungen gefunden</h3>
|
||
<p className="mt-2 text-gray-500">
|
||
{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.'
|
||
}
|
||
</p>
|
||
{(selectedCategory !== 'all' || selectedStatus !== 'all' || selectedPriority !== 'all') && (
|
||
<button
|
||
onClick={clearFilters}
|
||
className="mt-4 px-4 py-2 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors"
|
||
>
|
||
Filter zuruecksetzen
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Modals */}
|
||
{showCreateModal && (
|
||
<WhistleblowerCreateModal
|
||
onClose={() => setShowCreateModal(false)}
|
||
onSuccess={() => { setShowCreateModal(false); loadData() }}
|
||
/>
|
||
)}
|
||
{selectedReport && (
|
||
<CaseDetailPanel
|
||
report={selectedReport}
|
||
onClose={() => setSelectedReport(null)}
|
||
onUpdated={() => { setSelectedReport(null); loadData() }}
|
||
onDeleted={() => { setSelectedReport(null); loadData() }}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|