feat: add frontend pages, API routes and libs for all SDK modules
Add Next.js pages for Academy, Whistleblower, Incidents, Document Crawler, DSB Portal, Industry Templates, Multi-Tenant and SSO. Add API proxy routes and TypeScript SDK client libraries. Add server binary to .gitignore. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
669
admin-compliance/app/(sdk)/sdk/whistleblower/page.tsx
Normal file
669
admin-compliance/app/(sdk)/sdk/whistleblower/page.tsx
Normal file
@@ -0,0 +1,669 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useMemo } 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 (
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-1 -mb-px" aria-label="Tabs">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => 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'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className={`
|
||||
px-2 py-0.5 text-xs rounded-full
|
||||
${tab.countColor || 'bg-gray-100 text-gray-600'}
|
||||
`}>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`bg-white rounded-xl border ${colorClasses[color]} p-6`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className={`text-sm ${color === 'gray' ? 'text-gray-500' : `text-${color}-600`}`}>
|
||||
{label}
|
||||
</div>
|
||||
<div className={`text-3xl font-bold mt-1 ${colorClasses[color].split(' ')[1]}`}>
|
||||
{value}
|
||||
</div>
|
||||
{trend && (
|
||||
<div className={`text-xs mt-1 ${trend.value >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center bg-${color}-50`}>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
|
||||
{/* Category Filter */}
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="all">Alle Kategorien</option>
|
||||
{Object.entries(REPORT_CATEGORY_INFO).map(([key, info]) => (
|
||||
<option key={key} value={key}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="all">Alle Status</option>
|
||||
{Object.entries(REPORT_STATUS_INFO).map(([status, info]) => (
|
||||
<option key={status} value={status}>{info.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Priority Filter */}
|
||||
<select
|
||||
value={selectedPriority}
|
||||
onChange={(e) => 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"
|
||||
>
|
||||
<option value="all">Alle Prioritaeten</option>
|
||||
<option value="critical">Kritisch</option>
|
||||
<option value="high">Hoch</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="low">Niedrig</option>
|
||||
</select>
|
||||
|
||||
{/* Clear Filters */}
|
||||
{hasFilters && (
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ReportCard({ report }: { report: WhistleblowerReport }) {
|
||||
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 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>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
const loadData = 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)
|
||||
}
|
||||
}
|
||||
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 - NO "create report" button (reports come from the public form) */}
|
||||
<StepHeader
|
||||
stepId="whistleblower"
|
||||
title={stepInfo.title}
|
||||
description={stepInfo.description}
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
/>
|
||||
|
||||
{/* 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 Deadlines (Overview Tab) */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<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">HinSchG-Fristen</h4>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
Nach dem Hinweisgeberschutzgesetz (HinSchG) gelten folgende Fristen:
|
||||
Die Eingangsbestaetigung muss innerhalb von <strong>7 Tagen</strong> an den
|
||||
Hinweisgeber versendet werden (ss 17 Abs. 1 S. 2).
|
||||
Eine Rueckmeldung ueber ergriffene Massnahmen muss innerhalb von <strong>3 Monaten</strong> nach
|
||||
Eingangsbestaetigung erfolgen (ss 17 Abs. 2).
|
||||
Der Schutz des Hinweisgebers vor Repressalien ist zwingend sicherzustellen (ss 36).
|
||||
</p>
|
||||
</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} />
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user