feat(admin-v2): Major SDK/Compliance overhaul and new modules

SDK modules added/enhanced:
- compliance-hub, compliance-scope, consent-management, notfallplan
- audit-report, workflow, source-policy, dsms
- advisory-board documentation section
- TOM dashboard components, TOM generator SDM mapping
- DSFA: mitigation library, risk catalog, threshold analysis, source attribution
- VVT: baseline catalog, profiling engine, types
- Loeschfristen: baseline catalog, compliance engine, export, profiling, types
- Compliance scope: engine, profiling, golden tests, types

Existing SDK pages updated:
- dsfa/[id], tom, vvt, loeschfristen, advisory-board — expanded functionality
- SDKSidebar, StepHeader — new navigation items and layout
- SDK layout, context, types — expanded type system

Other admin-v2 changes:
- AI agents page, RAG pipeline DSFA integration
- GridOverlay component updates
- Companion feature (development + education)
- Compliance advisor SOUL definition

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BreakPilot Dev
2026-02-10 00:01:04 +01:00
parent 53219e3eaf
commit dff2ef796b
94 changed files with 29706 additions and 1039 deletions

View File

@@ -0,0 +1,312 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Settings, MessageSquare, HelpCircle, RefreshCw } from 'lucide-react'
import { CompanionMode, TeacherSettings, FeedbackType } from '@/lib/companion/types'
import { DEFAULT_TEACHER_SETTINGS, STORAGE_KEYS } from '@/lib/companion/constants'
// Components
import { ModeToggle } from './ModeToggle'
import { PhaseTimeline } from './companion-mode/PhaseTimeline'
import { StatsGrid } from './companion-mode/StatsGrid'
import { SuggestionList } from './companion-mode/SuggestionList'
import { EventsCard } from './companion-mode/EventsCard'
import { LessonContainer } from './lesson-mode/LessonContainer'
import { SettingsModal } from './modals/SettingsModal'
import { FeedbackModal } from './modals/FeedbackModal'
import { OnboardingModal } from './modals/OnboardingModal'
// Hooks
import { useCompanionData } from '@/hooks/companion/useCompanionData'
import { useLessonSession } from '@/hooks/companion/useLessonSession'
import { useKeyboardShortcuts } from '@/hooks/companion/useKeyboardShortcuts'
export function CompanionDashboard() {
// Mode state
const [mode, setMode] = useState<CompanionMode>('companion')
// Modal states
const [showSettings, setShowSettings] = useState(false)
const [showFeedback, setShowFeedback] = useState(false)
const [showOnboarding, setShowOnboarding] = useState(false)
// Settings
const [settings, setSettings] = useState<TeacherSettings>(DEFAULT_TEACHER_SETTINGS)
// Load settings from localStorage
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.SETTINGS)
if (stored) {
try {
const parsed = JSON.parse(stored)
setSettings({ ...DEFAULT_TEACHER_SETTINGS, ...parsed })
} catch {
// Invalid stored settings
}
}
// Check if onboarding needed
const onboardingStored = localStorage.getItem(STORAGE_KEYS.ONBOARDING_STATE)
if (!onboardingStored) {
setShowOnboarding(true)
}
// Restore last mode
const lastMode = localStorage.getItem(STORAGE_KEYS.LAST_MODE) as CompanionMode
if (lastMode && ['companion', 'lesson', 'classic'].includes(lastMode)) {
setMode(lastMode)
}
}, [])
// Save mode to localStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.LAST_MODE, mode)
}, [mode])
// Companion data hook
const { data: companionData, loading: companionLoading, refresh } = useCompanionData()
// Lesson session hook
const {
session,
startLesson,
endLesson,
pauseLesson,
resumeLesson,
extendTime,
skipPhase,
saveReflection,
addHomework,
removeHomework,
isPaused,
} = useLessonSession({
onOvertimeStart: () => {
// Play sound if enabled
if (settings.soundNotifications) {
// TODO: Play notification sound
}
},
})
// Handle pause/resume toggle
const handlePauseToggle = useCallback(() => {
if (isPaused) {
resumeLesson()
} else {
pauseLesson()
}
}, [isPaused, pauseLesson, resumeLesson])
// Keyboard shortcuts
useKeyboardShortcuts({
onPauseResume: mode === 'lesson' && session ? handlePauseToggle : undefined,
onExtend: mode === 'lesson' && session && !isPaused ? () => extendTime(5) : undefined,
onNextPhase: mode === 'lesson' && session && !isPaused ? skipPhase : undefined,
onCloseModal: () => {
setShowSettings(false)
setShowFeedback(false)
setShowOnboarding(false)
},
enabled: settings.showKeyboardShortcuts,
})
// Handle settings save
const handleSaveSettings = (newSettings: TeacherSettings) => {
setSettings(newSettings)
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(newSettings))
}
// Handle feedback submit
const handleFeedbackSubmit = async (type: FeedbackType, title: string, description: string) => {
const response = await fetch('/api/admin/companion/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
title,
description,
sessionId: session?.sessionId,
}),
})
if (!response.ok) {
throw new Error('Failed to submit feedback')
}
}
// Handle onboarding complete
const handleOnboardingComplete = (data: { state?: string; schoolType?: string }) => {
localStorage.setItem(STORAGE_KEYS.ONBOARDING_STATE, JSON.stringify({
...data,
completed: true,
completedAt: new Date().toISOString(),
}))
setShowOnboarding(false)
setSettings({ ...settings, onboardingCompleted: true })
}
// Handle lesson start
const handleStartLesson = (data: { classId: string; subject: string; topic?: string; templateId?: string }) => {
startLesson(data)
setMode('lesson')
}
return (
<div className={`min-h-[calc(100vh-200px)] ${settings.highContrastMode ? 'high-contrast' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<ModeToggle
currentMode={mode}
onModeChange={setMode}
disabled={!!session && session.status === 'in_progress'}
/>
<div className="flex items-center gap-2">
{/* Refresh Button */}
{mode === 'companion' && (
<button
onClick={refresh}
disabled={companionLoading}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Aktualisieren"
>
<RefreshCw className={`w-5 h-5 ${companionLoading ? 'animate-spin' : ''}`} />
</button>
)}
{/* Feedback Button */}
<button
onClick={() => setShowFeedback(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Feedback"
>
<MessageSquare className="w-5 h-5" />
</button>
{/* Settings Button */}
<button
onClick={() => setShowSettings(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Einstellungen"
>
<Settings className="w-5 h-5" />
</button>
{/* Help Button */}
<button
onClick={() => setShowOnboarding(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Hilfe"
>
<HelpCircle className="w-5 h-5" />
</button>
</div>
</div>
{/* Main Content */}
{mode === 'companion' && (
<div className="space-y-6">
{/* Phase Timeline */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Aktuelle Phase</h3>
{companionData ? (
<PhaseTimeline
phases={companionData.phases}
currentPhaseIndex={companionData.phases.findIndex(p => p.status === 'active')}
/>
) : (
<div className="h-10 bg-slate-100 rounded animate-pulse" />
)}
</div>
{/* Stats */}
<StatsGrid
stats={companionData?.stats || { classesCount: 0, studentsCount: 0, learningUnitsCreated: 0, gradesEntered: 0 }}
loading={companionLoading}
/>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Suggestions */}
<SuggestionList
suggestions={companionData?.suggestions || []}
loading={companionLoading}
onSuggestionClick={(suggestion) => {
// Navigate to action target
window.location.href = suggestion.actionTarget
}}
/>
{/* Events */}
<EventsCard
events={companionData?.upcomingEvents || []}
loading={companionLoading}
/>
</div>
{/* Quick Start Lesson Button */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl p-6 text-white">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold mb-1">Bereit fuer die naechste Stunde?</h3>
<p className="text-blue-100">Starten Sie den Lesson-Modus fuer strukturierten Unterricht.</p>
</div>
<button
onClick={() => setMode('lesson')}
className="px-6 py-3 bg-white text-blue-600 rounded-xl font-semibold hover:bg-blue-50 transition-colors"
>
Stunde starten
</button>
</div>
</div>
</div>
)}
{mode === 'lesson' && (
<LessonContainer
session={session}
onStartLesson={handleStartLesson}
onEndLesson={endLesson}
onPauseToggle={handlePauseToggle}
onExtendTime={extendTime}
onSkipPhase={skipPhase}
onSaveReflection={saveReflection}
onAddHomework={addHomework}
onRemoveHomework={removeHomework}
/>
)}
{mode === 'classic' && (
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
<h2 className="text-xl font-semibold text-slate-800 mb-2">Classic Mode</h2>
<p className="text-slate-600 mb-4">
Die klassische Ansicht ohne Timer und Phasenstruktur.
</p>
<p className="text-sm text-slate-500">
Dieser Modus ist fuer flexible Unterrichtsgestaltung gedacht.
</p>
</div>
)}
{/* Modals */}
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
settings={settings}
onSave={handleSaveSettings}
/>
<FeedbackModal
isOpen={showFeedback}
onClose={() => setShowFeedback(false)}
onSubmit={handleFeedbackSubmit}
/>
<OnboardingModal
isOpen={showOnboarding}
onClose={() => setShowOnboarding(false)}
onComplete={handleOnboardingComplete}
/>
</div>
)
}

View File

@@ -0,0 +1,61 @@
'use client'
import { GraduationCap, Timer, Layout } from 'lucide-react'
import { CompanionMode } from '@/lib/companion/types'
interface ModeToggleProps {
currentMode: CompanionMode
onModeChange: (mode: CompanionMode) => void
disabled?: boolean
}
const modes: { id: CompanionMode; label: string; icon: React.ReactNode; description: string }[] = [
{
id: 'companion',
label: 'Companion',
icon: <GraduationCap className="w-4 h-4" />,
description: 'Dashboard mit Vorschlaegen',
},
{
id: 'lesson',
label: 'Lesson',
icon: <Timer className="w-4 h-4" />,
description: 'Timer und Phasen',
},
{
id: 'classic',
label: 'Classic',
icon: <Layout className="w-4 h-4" />,
description: 'Klassische Ansicht',
},
]
export function ModeToggle({ currentMode, onModeChange, disabled }: ModeToggleProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-1 inline-flex gap-1">
{modes.map((mode) => {
const isActive = currentMode === mode.id
return (
<button
key={mode.id}
onClick={() => onModeChange(mode.id)}
disabled={disabled}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium
transition-all duration-200
${isActive
? 'bg-slate-900 text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
title={mode.description}
>
{mode.icon}
<span>{mode.label}</span>
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,173 @@
'use client'
import { Calendar, FileQuestion, Users, Clock, ChevronRight } from 'lucide-react'
import { UpcomingEvent, EventType } from '@/lib/companion/types'
import { EVENT_TYPE_CONFIG } from '@/lib/companion/constants'
interface EventsCardProps {
events: UpcomingEvent[]
onEventClick?: (event: UpcomingEvent) => void
loading?: boolean
maxItems?: number
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
FileQuestion,
Users,
Clock,
Calendar,
}
function getEventIcon(type: EventType) {
const config = EVENT_TYPE_CONFIG[type]
const Icon = iconMap[config.icon] || Calendar
return { Icon, ...config }
}
function formatEventDate(dateStr: string, inDays: number): string {
if (inDays === 0) return 'Heute'
if (inDays === 1) return 'Morgen'
if (inDays < 7) return `In ${inDays} Tagen`
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
interface EventItemProps {
event: UpcomingEvent
onClick?: () => void
}
function EventItem({ event, onClick }: EventItemProps) {
const { Icon, color, bg } = getEventIcon(event.type)
const isUrgent = event.inDays <= 2
return (
<button
onClick={onClick}
className={`
w-full flex items-center gap-3 p-3 rounded-lg
transition-all duration-200
hover:bg-slate-50
${isUrgent ? 'bg-red-50/50' : ''}
`}
>
<div className={`p-2 rounded-lg ${bg}`}>
<Icon className={`w-4 h-4 ${color}`} />
</div>
<div className="flex-1 text-left min-w-0">
<p className="font-medium text-slate-900 truncate">{event.title}</p>
<p className={`text-sm ${isUrgent ? 'text-red-600 font-medium' : 'text-slate-500'}`}>
{formatEventDate(event.date, event.inDays)}
</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-400 flex-shrink-0" />
</button>
)
}
export function EventsCard({
events,
onEventClick,
loading,
maxItems = 5,
}: EventsCardProps) {
const displayEvents = events.slice(0, maxItems)
if (loading) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-14 bg-slate-100 rounded-lg animate-pulse" />
))}
</div>
</div>
)
}
if (events.length === 0) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<div className="text-center py-6">
<Calendar className="w-10 h-10 text-slate-300 mx-auto mb-2" />
<p className="text-sm text-slate-500">Keine anstehenden Termine</p>
</div>
</div>
)
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<span className="text-sm text-slate-500">
{events.length} Termin{events.length !== 1 ? 'e' : ''}
</span>
</div>
<div className="space-y-1">
{displayEvents.map((event) => (
<EventItem
key={event.id}
event={event}
onClick={() => onEventClick?.(event)}
/>
))}
</div>
{events.length > maxItems && (
<button className="w-full mt-3 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors">
Alle {events.length} anzeigen
</button>
)}
</div>
)
}
/**
* Compact inline version for header/toolbar
*/
export function EventsInline({ events }: { events: UpcomingEvent[] }) {
const nextEvent = events[0]
if (!nextEvent) {
return (
<div className="flex items-center gap-2 text-sm text-slate-500">
<Calendar className="w-4 h-4" />
<span>Keine Termine</span>
</div>
)
}
const { Icon, color } = getEventIcon(nextEvent.type)
const isUrgent = nextEvent.inDays <= 2
return (
<div className={`flex items-center gap-2 text-sm ${isUrgent ? 'text-red-600' : 'text-slate-600'}`}>
<Icon className={`w-4 h-4 ${color}`} />
<span className="truncate max-w-[150px]">{nextEvent.title}</span>
<span className="text-slate-400">-</span>
<span className={isUrgent ? 'font-medium' : ''}>
{formatEventDate(nextEvent.date, nextEvent.inDays)}
</span>
</div>
)
}

View File

@@ -0,0 +1,203 @@
'use client'
import { Check } from 'lucide-react'
import { Phase } from '@/lib/companion/types'
import { PHASE_COLORS, formatMinutes } from '@/lib/companion/constants'
interface PhaseTimelineProps {
phases: Phase[]
currentPhaseIndex: number
onPhaseClick?: (index: number) => void
compact?: boolean
}
export function PhaseTimeline({
phases,
currentPhaseIndex,
onPhaseClick,
compact = false,
}: PhaseTimelineProps) {
return (
<div className={`flex items-center ${compact ? 'gap-2' : 'gap-3'}`}>
{phases.map((phase, index) => {
const isActive = index === currentPhaseIndex
const isCompleted = phase.status === 'completed'
const isPast = index < currentPhaseIndex
const colors = PHASE_COLORS[phase.id]
return (
<div key={phase.id} className="flex items-center">
{/* Phase Dot/Circle */}
<button
onClick={() => onPhaseClick?.(index)}
disabled={!onPhaseClick}
className={`
relative flex items-center justify-center
${compact ? 'w-8 h-8' : 'w-10 h-10'}
rounded-full font-semibold text-sm
transition-all duration-300
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
${isActive
? `ring-4 ring-offset-2 ${colors.tailwind} text-white`
: isCompleted || isPast
? `${colors.tailwind} text-white opacity-80`
: 'bg-slate-200 text-slate-500'
}
`}
style={{
backgroundColor: isActive || isCompleted || isPast ? colors.hex : undefined,
// Use CSS custom property for ring color with Tailwind
'--tw-ring-color': isActive ? colors.hex : undefined,
} as React.CSSProperties}
title={`${phase.displayName} (${formatMinutes(phase.duration)})`}
>
{isCompleted ? (
<Check className={compact ? 'w-4 h-4' : 'w-5 h-5'} />
) : (
phase.shortName
)}
{/* Active indicator pulse */}
{isActive && (
<span
className="absolute inset-0 rounded-full animate-ping opacity-30"
style={{ backgroundColor: colors.hex }}
/>
)}
</button>
{/* Connector Line */}
{index < phases.length - 1 && (
<div
className={`
${compact ? 'w-4' : 'w-8'} h-1 mx-1
${isPast || isCompleted
? 'bg-gradient-to-r'
: 'bg-slate-200'
}
`}
style={{
background: isPast || isCompleted
? `linear-gradient(to right, ${colors.hex}, ${PHASE_COLORS[phases[index + 1].id].hex})`
: undefined,
}}
/>
)}
</div>
)
})}
</div>
)
}
/**
* Detailed Phase Timeline with labels and durations
*/
export function PhaseTimelineDetailed({
phases,
currentPhaseIndex,
onPhaseClick,
}: PhaseTimelineProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Unterrichtsphasen</h3>
<div className="flex items-start justify-between">
{phases.map((phase, index) => {
const isActive = index === currentPhaseIndex
const isCompleted = phase.status === 'completed'
const isPast = index < currentPhaseIndex
const colors = PHASE_COLORS[phase.id]
return (
<div key={phase.id} className="flex flex-col items-center flex-1">
{/* Top connector line */}
<div className="w-full flex items-center mb-2">
{index > 0 && (
<div
className="flex-1 h-1"
style={{
background: isPast || isCompleted
? PHASE_COLORS[phases[index - 1].id].hex
: '#e2e8f0',
}}
/>
)}
{index === 0 && <div className="flex-1" />}
{/* Phase Circle */}
<button
onClick={() => onPhaseClick?.(index)}
disabled={!onPhaseClick}
className={`
relative w-12 h-12 rounded-full
flex items-center justify-center
font-bold text-lg
transition-all duration-300
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
${isActive ? 'ring-4 ring-offset-2 shadow-lg' : ''}
`}
style={{
backgroundColor: isActive || isCompleted || isPast ? colors.hex : '#e2e8f0',
color: isActive || isCompleted || isPast ? 'white' : '#64748b',
'--tw-ring-color': isActive ? `${colors.hex}40` : undefined,
} as React.CSSProperties}
>
{isCompleted ? (
<Check className="w-6 h-6" />
) : (
phase.shortName
)}
{isActive && (
<span
className="absolute inset-0 rounded-full animate-ping opacity-20"
style={{ backgroundColor: colors.hex }}
/>
)}
</button>
{index < phases.length - 1 && (
<div
className="flex-1 h-1"
style={{
background: isCompleted ? colors.hex : '#e2e8f0',
}}
/>
)}
{index === phases.length - 1 && <div className="flex-1" />}
</div>
{/* Phase Label */}
<span
className={`
text-sm font-medium mt-2
${isActive ? 'text-slate-900' : 'text-slate-500'}
`}
>
{phase.displayName}
</span>
{/* Duration */}
<span
className={`
text-xs mt-1
${isActive ? 'text-slate-700' : 'text-slate-400'}
`}
>
{formatMinutes(phase.duration)}
</span>
{/* Actual time if completed */}
{phase.actualTime !== undefined && phase.actualTime > 0 && (
<span className="text-xs text-slate-400 mt-0.5">
(tatsaechlich: {Math.round(phase.actualTime / 60)} Min)
</span>
)}
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
'use client'
import { Users, GraduationCap, BookOpen, FileCheck } from 'lucide-react'
import { CompanionStats } from '@/lib/companion/types'
interface StatsGridProps {
stats: CompanionStats
loading?: boolean
}
interface StatCardProps {
label: string
value: number
icon: React.ReactNode
color: string
loading?: boolean
}
function StatCard({ label, value, icon, color, loading }: StatCardProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500 mb-1">{label}</p>
{loading ? (
<div className="h-8 w-16 bg-slate-200 rounded animate-pulse" />
) : (
<p className="text-2xl font-bold text-slate-900">{value}</p>
)}
</div>
<div className={`p-2 rounded-lg ${color}`}>
{icon}
</div>
</div>
</div>
)
}
export function StatsGrid({ stats, loading }: StatsGridProps) {
const statCards = [
{
label: 'Klassen',
value: stats.classesCount,
icon: <Users className="w-5 h-5 text-blue-600" />,
color: 'bg-blue-100',
},
{
label: 'Schueler',
value: stats.studentsCount,
icon: <GraduationCap className="w-5 h-5 text-green-600" />,
color: 'bg-green-100',
},
{
label: 'Lerneinheiten',
value: stats.learningUnitsCreated,
icon: <BookOpen className="w-5 h-5 text-purple-600" />,
color: 'bg-purple-100',
},
{
label: 'Noten',
value: stats.gradesEntered,
icon: <FileCheck className="w-5 h-5 text-amber-600" />,
color: 'bg-amber-100',
},
]
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{statCards.map((card) => (
<StatCard
key={card.label}
label={card.label}
value={card.value}
icon={card.icon}
color={card.color}
loading={loading}
/>
))}
</div>
)
}
/**
* Compact version of StatsGrid for sidebar or smaller spaces
*/
export function StatsGridCompact({ stats, loading }: StatsGridProps) {
const items = [
{ label: 'Klassen', value: stats.classesCount, icon: <Users className="w-4 h-4" /> },
{ label: 'Schueler', value: stats.studentsCount, icon: <GraduationCap className="w-4 h-4" /> },
{ label: 'Einheiten', value: stats.learningUnitsCreated, icon: <BookOpen className="w-4 h-4" /> },
{ label: 'Noten', value: stats.gradesEntered, icon: <FileCheck className="w-4 h-4" /> },
]
return (
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-medium text-slate-500 mb-3">Statistiken</h3>
<div className="space-y-3">
{items.map((item) => (
<div key={item.label} className="flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-600">
{item.icon}
<span className="text-sm">{item.label}</span>
</div>
{loading ? (
<div className="h-5 w-8 bg-slate-200 rounded animate-pulse" />
) : (
<span className="font-semibold text-slate-900">{item.value}</span>
)}
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,170 @@
'use client'
import { ChevronRight, Clock, Lightbulb, ClipboardCheck, BookOpen, Calendar, Users, MessageSquare, FileText } from 'lucide-react'
import { Suggestion, SuggestionPriority } from '@/lib/companion/types'
import { PRIORITY_COLORS } from '@/lib/companion/constants'
interface SuggestionListProps {
suggestions: Suggestion[]
onSuggestionClick?: (suggestion: Suggestion) => void
loading?: boolean
maxItems?: number
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
ClipboardCheck,
BookOpen,
Calendar,
Users,
Clock,
MessageSquare,
FileText,
Lightbulb,
}
function getIcon(iconName: string) {
const Icon = iconMap[iconName] || Lightbulb
return Icon
}
interface SuggestionCardProps {
suggestion: Suggestion
onClick?: () => void
}
function SuggestionCard({ suggestion, onClick }: SuggestionCardProps) {
const priorityStyles = PRIORITY_COLORS[suggestion.priority]
const Icon = getIcon(suggestion.icon)
return (
<button
onClick={onClick}
className={`
w-full p-4 rounded-xl border text-left
transition-all duration-200
hover:shadow-md hover:scale-[1.01]
${priorityStyles.bg} ${priorityStyles.border}
`}
>
<div className="flex items-start gap-3">
{/* Priority Dot & Icon */}
<div className="flex-shrink-0 relative">
<div className={`p-2 rounded-lg bg-white shadow-sm`}>
<Icon className={`w-5 h-5 ${priorityStyles.text}`} />
</div>
<span
className={`absolute -top-1 -right-1 w-3 h-3 rounded-full ${priorityStyles.dot}`}
title={`Prioritaet: ${suggestion.priority}`}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h4 className={`font-medium ${priorityStyles.text} mb-1`}>
{suggestion.title}
</h4>
<p className="text-sm text-slate-600 line-clamp-2">
{suggestion.description}
</p>
{/* Meta */}
<div className="flex items-center gap-3 mt-2">
<span className="inline-flex items-center gap-1 text-xs text-slate-500">
<Clock className="w-3 h-3" />
~{suggestion.estimatedTime} Min
</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${priorityStyles.bg} ${priorityStyles.text}`}>
{suggestion.priority}
</span>
</div>
</div>
{/* Arrow */}
<ChevronRight className="w-5 h-5 text-slate-400 flex-shrink-0" />
</div>
</button>
)
}
export function SuggestionList({
suggestions,
onSuggestionClick,
loading,
maxItems = 5,
}: SuggestionListProps) {
// Sort by priority: urgent > high > medium > low
const priorityOrder: Record<SuggestionPriority, number> = {
urgent: 0,
high: 1,
medium: 2,
low: 3,
}
const sortedSuggestions = [...suggestions]
.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
.slice(0, maxItems)
if (loading) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-24 bg-slate-100 rounded-xl animate-pulse" />
))}
</div>
</div>
)
}
if (suggestions.length === 0) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<div className="text-center py-8">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
<ClipboardCheck className="w-6 h-6 text-green-600" />
</div>
<p className="text-slate-600">Alles erledigt!</p>
<p className="text-sm text-slate-500 mt-1">Keine offenen Aufgaben</p>
</div>
</div>
)
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<span className="text-sm text-slate-500">
{suggestions.length} Aufgabe{suggestions.length !== 1 ? 'n' : ''}
</span>
</div>
<div className="space-y-3">
{sortedSuggestions.map((suggestion) => (
<SuggestionCard
key={suggestion.id}
suggestion={suggestion}
onClick={() => onSuggestionClick?.(suggestion)}
/>
))}
</div>
{suggestions.length > maxItems && (
<button className="w-full mt-4 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors">
Alle {suggestions.length} anzeigen
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,24 @@
// Main components
export { CompanionDashboard } from './CompanionDashboard'
export { ModeToggle } from './ModeToggle'
// Companion Mode components
export { PhaseTimeline, PhaseTimelineDetailed } from './companion-mode/PhaseTimeline'
export { StatsGrid, StatsGridCompact } from './companion-mode/StatsGrid'
export { SuggestionList } from './companion-mode/SuggestionList'
export { EventsCard, EventsInline } from './companion-mode/EventsCard'
// Lesson Mode components
export { LessonContainer } from './lesson-mode/LessonContainer'
export { LessonStartForm } from './lesson-mode/LessonStartForm'
export { LessonActiveView } from './lesson-mode/LessonActiveView'
export { LessonEndedView } from './lesson-mode/LessonEndedView'
export { VisualPieTimer, CompactTimer } from './lesson-mode/VisualPieTimer'
export { QuickActionsBar, QuickActionsCompact } from './lesson-mode/QuickActionsBar'
export { HomeworkSection } from './lesson-mode/HomeworkSection'
export { ReflectionSection } from './lesson-mode/ReflectionSection'
// Modals
export { SettingsModal } from './modals/SettingsModal'
export { FeedbackModal } from './modals/FeedbackModal'
export { OnboardingModal } from './modals/OnboardingModal'

View File

@@ -0,0 +1,153 @@
'use client'
import { useState } from 'react'
import { Plus, Trash2, BookOpen, Calendar } from 'lucide-react'
import { Homework } from '@/lib/companion/types'
interface HomeworkSectionProps {
homeworkList: Homework[]
onAdd: (title: string, dueDate: string) => void
onRemove: (id: string) => void
}
export function HomeworkSection({ homeworkList, onAdd, onRemove }: HomeworkSectionProps) {
const [newTitle, setNewTitle] = useState('')
const [newDueDate, setNewDueDate] = useState('')
const [isAdding, setIsAdding] = useState(false)
// Default due date to next week
const getDefaultDueDate = () => {
const date = new Date()
date.setDate(date.getDate() + 7)
return date.toISOString().split('T')[0]
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!newTitle.trim()) return
onAdd(newTitle.trim(), newDueDate || getDefaultDueDate())
setNewTitle('')
setNewDueDate('')
setIsAdding(false)
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<BookOpen className="w-5 h-5 text-slate-400" />
Hausaufgaben
</h3>
{!isAdding && (
<button
onClick={() => setIsAdding(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
Hinzufuegen
</button>
)}
</div>
{/* Add Form */}
{isAdding && (
<form onSubmit={handleSubmit} className="mb-4 p-4 bg-blue-50 rounded-xl">
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgabe
</label>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="z.B. Aufgabe 1-5 auf S. 42..."
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Faellig am
</label>
<input
type="date"
value={newDueDate}
onChange={(e) => setNewDueDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={!newTitle.trim()}
className="flex-1 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Speichern
</button>
<button
type="button"
onClick={() => {
setIsAdding(false)
setNewTitle('')
setNewDueDate('')
}}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
</div>
</div>
</form>
)}
{/* Homework List */}
{homeworkList.length === 0 ? (
<div className="text-center py-8">
<BookOpen className="w-10 h-10 text-slate-300 mx-auto mb-2" />
<p className="text-slate-500">Keine Hausaufgaben eingetragen</p>
<p className="text-sm text-slate-400 mt-1">
Fuegen Sie Hausaufgaben hinzu, um sie zu dokumentieren
</p>
</div>
) : (
<div className="space-y-3">
{homeworkList.map((hw) => (
<div
key={hw.id}
className="flex items-start gap-3 p-4 bg-slate-50 rounded-xl group"
>
<div className="flex-1">
<p className="font-medium text-slate-900">{hw.title}</p>
<div className="flex items-center gap-2 mt-1 text-sm text-slate-500">
<Calendar className="w-3 h-3" />
<span>Faellig: {formatDate(hw.dueDate)}</span>
</div>
</div>
<button
onClick={() => onRemove(hw.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
title="Entfernen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,172 @@
'use client'
import { BookOpen, Clock, Users } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { VisualPieTimer } from './VisualPieTimer'
import { QuickActionsBar } from './QuickActionsBar'
import { PhaseTimelineDetailed } from '../companion-mode/PhaseTimeline'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,
formatTime,
getTimerColorStatus,
} from '@/lib/companion/constants'
interface LessonActiveViewProps {
session: LessonSession
onPauseToggle: () => void
onExtendTime: (minutes: number) => void
onSkipPhase: () => void
onEndLesson: () => void
}
export function LessonActiveView({
session,
onPauseToggle,
onExtendTime,
onSkipPhase,
onEndLesson,
}: LessonActiveViewProps) {
const currentPhase = session.phases[session.currentPhaseIndex]
const phaseId = currentPhase?.phase || 'einstieg'
const phaseColor = PHASE_COLORS[phaseId].hex
const phaseName = PHASE_DISPLAY_NAMES[phaseId]
// Calculate timer values
const phaseDurationSeconds = (currentPhase?.duration || 0) * 60
const elapsedInPhase = currentPhase?.actualTime || 0
const remainingSeconds = phaseDurationSeconds - elapsedInPhase
const progress = Math.min(elapsedInPhase / phaseDurationSeconds, 1)
const isOvertime = remainingSeconds < 0
const colorStatus = getTimerColorStatus(remainingSeconds, isOvertime)
const isLastPhase = session.currentPhaseIndex === session.phases.length - 1
// Calculate total elapsed
const totalElapsedMinutes = Math.floor(session.elapsedTime / 60)
return (
<div className="space-y-6">
{/* Header with Session Info */}
<div
className="bg-gradient-to-r rounded-xl p-6 text-white"
style={{
background: `linear-gradient(135deg, ${phaseColor}, ${phaseColor}dd)`,
}}
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-white/80 text-sm mb-1">
<Users className="w-4 h-4" />
<span>{session.className}</span>
<span className="mx-2">|</span>
<BookOpen className="w-4 h-4" />
<span>{session.subject}</span>
</div>
<h2 className="text-2xl font-bold">
{session.topic || phaseName}
</h2>
</div>
<div className="text-right">
<div className="text-white/80 text-sm">Gesamtzeit</div>
<div className="text-xl font-mono font-bold">
{formatTime(session.elapsedTime)}
</div>
</div>
</div>
</div>
{/* Main Timer Section */}
<div className="bg-white border border-slate-200 rounded-xl p-8">
<div className="flex flex-col items-center">
{/* Visual Pie Timer */}
<VisualPieTimer
progress={progress}
remainingSeconds={remainingSeconds}
totalSeconds={phaseDurationSeconds}
colorStatus={colorStatus}
isPaused={session.isPaused}
currentPhaseName={phaseName}
phaseColor={phaseColor}
onTogglePause={onPauseToggle}
size="lg"
/>
{/* Quick Actions */}
<div className="mt-8 w-full max-w-md">
<QuickActionsBar
onExtend={onExtendTime}
onPause={onPauseToggle}
onResume={onPauseToggle}
onSkip={onSkipPhase}
onEnd={onEndLesson}
isPaused={session.isPaused}
isLastPhase={isLastPhase}
/>
</div>
</div>
</div>
{/* Phase Timeline */}
<PhaseTimelineDetailed
phases={session.phases.map((p, i) => ({
id: p.phase,
shortName: p.phase[0].toUpperCase(),
displayName: PHASE_DISPLAY_NAMES[p.phase],
duration: p.duration,
status: p.status === 'active' ? 'active' : p.status === 'completed' ? 'completed' : 'planned',
actualTime: p.actualTime,
color: PHASE_COLORS[p.phase].hex,
}))}
currentPhaseIndex={session.currentPhaseIndex}
onPhaseClick={(index) => {
// Optional: Allow clicking to navigate to a phase
}}
/>
{/* Lesson Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-slate-900">{totalElapsedMinutes}</div>
<div className="text-sm text-slate-500">Minuten vergangen</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<div
className="w-5 h-5 rounded-full mx-auto mb-2"
style={{ backgroundColor: phaseColor }}
/>
<div className="text-2xl font-bold text-slate-900">
{session.currentPhaseIndex + 1}/{session.phases.length}
</div>
<div className="text-sm text-slate-500">Phase</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-slate-900">
{session.totalPlannedDuration - totalElapsedMinutes}
</div>
<div className="text-sm text-slate-500">Minuten verbleibend</div>
</div>
</div>
{/* Keyboard Shortcuts Hint */}
<div className="text-center text-sm text-slate-400">
<span className="inline-flex items-center gap-4">
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">Leertaste</kbd> Pause
</span>
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">E</kbd> +5 Min
</span>
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">N</kbd> Weiter
</span>
</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,80 @@
'use client'
import { LessonSession, LessonStatus } from '@/lib/companion/types'
import { LessonStartForm } from './LessonStartForm'
import { LessonActiveView } from './LessonActiveView'
import { LessonEndedView } from './LessonEndedView'
interface LessonContainerProps {
session: LessonSession | null
onStartLesson: (data: { classId: string; subject: string; topic?: string; templateId?: string }) => void
onEndLesson: () => void
onPauseToggle: () => void
onExtendTime: (minutes: number) => void
onSkipPhase: () => void
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
onAddHomework: (title: string, dueDate: string) => void
onRemoveHomework: (id: string) => void
loading?: boolean
}
export function LessonContainer({
session,
onStartLesson,
onEndLesson,
onPauseToggle,
onExtendTime,
onSkipPhase,
onSaveReflection,
onAddHomework,
onRemoveHomework,
loading,
}: LessonContainerProps) {
// Determine which view to show based on session state
const getView = (): 'start' | 'active' | 'ended' => {
if (!session) return 'start'
const status = session.status
if (status === 'completed') return 'ended'
if (status === 'not_started') return 'start'
return 'active'
}
const view = getView()
if (view === 'start') {
return (
<LessonStartForm
onStart={onStartLesson}
loading={loading}
/>
)
}
if (view === 'ended' && session) {
return (
<LessonEndedView
session={session}
onSaveReflection={onSaveReflection}
onAddHomework={onAddHomework}
onRemoveHomework={onRemoveHomework}
onStartNew={() => onEndLesson()} // This will clear the session and show start form
/>
)
}
if (session) {
return (
<LessonActiveView
session={session}
onPauseToggle={onPauseToggle}
onExtendTime={onExtendTime}
onSkipPhase={onSkipPhase}
onEndLesson={onEndLesson}
/>
)
}
return null
}

View File

@@ -0,0 +1,209 @@
'use client'
import { useState } from 'react'
import { CheckCircle, Clock, BarChart3, Plus, RefreshCw } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { HomeworkSection } from './HomeworkSection'
import { ReflectionSection } from './ReflectionSection'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,
formatTime,
formatMinutes,
} from '@/lib/companion/constants'
interface LessonEndedViewProps {
session: LessonSession
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
onAddHomework: (title: string, dueDate: string) => void
onRemoveHomework: (id: string) => void
onStartNew: () => void
}
export function LessonEndedView({
session,
onSaveReflection,
onAddHomework,
onRemoveHomework,
onStartNew,
}: LessonEndedViewProps) {
const [activeTab, setActiveTab] = useState<'summary' | 'homework' | 'reflection'>('summary')
// Calculate analytics
const totalPlannedSeconds = session.totalPlannedDuration * 60
const totalActualSeconds = session.elapsedTime
const timeDiff = totalActualSeconds - totalPlannedSeconds
const timeDiffMinutes = Math.round(timeDiff / 60)
const startTime = new Date(session.startTime)
const endTime = session.endTime ? new Date(session.endTime) : new Date()
return (
<div className="space-y-6">
{/* Success Header */}
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-xl p-6 text-white">
<div className="flex items-center gap-4">
<div className="p-3 bg-white/20 rounded-full">
<CheckCircle className="w-8 h-8" />
</div>
<div>
<h2 className="text-2xl font-bold">Stunde beendet!</h2>
<p className="text-green-100">
{session.className} - {session.subject}
{session.topic && ` - ${session.topic}`}
</p>
</div>
</div>
</div>
{/* Tab Navigation */}
<div className="bg-white border border-slate-200 rounded-xl p-1 flex">
{[
{ id: 'summary', label: 'Zusammenfassung', icon: BarChart3 },
{ id: 'homework', label: 'Hausaufgaben', icon: Plus },
{ id: 'reflection', label: 'Reflexion', icon: RefreshCw },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`
flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg
font-medium transition-all duration-200
${activeTab === tab.id
? 'bg-slate-900 text-white'
: 'text-slate-600 hover:bg-slate-100'
}
`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'summary' && (
<div className="space-y-6">
{/* Time Overview */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-slate-400" />
Zeitauswertung
</h3>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="text-center p-4 bg-slate-50 rounded-xl">
<div className="text-2xl font-bold text-slate-900">
{formatTime(totalActualSeconds)}
</div>
<div className="text-sm text-slate-500">Tatsaechlich</div>
</div>
<div className="text-center p-4 bg-slate-50 rounded-xl">
<div className="text-2xl font-bold text-slate-900">
{formatMinutes(session.totalPlannedDuration)}
</div>
<div className="text-sm text-slate-500">Geplant</div>
</div>
<div className={`text-center p-4 rounded-xl ${timeDiff > 0 ? 'bg-amber-50' : 'bg-green-50'}`}>
<div className={`text-2xl font-bold ${timeDiff > 0 ? 'text-amber-600' : 'text-green-600'}`}>
{timeDiffMinutes > 0 ? '+' : ''}{timeDiffMinutes} Min
</div>
<div className={`text-sm ${timeDiff > 0 ? 'text-amber-500' : 'text-green-500'}`}>
Differenz
</div>
</div>
</div>
{/* Session Times */}
<div className="flex items-center justify-between text-sm text-slate-500 border-t border-slate-100 pt-4">
<span>Start: {startTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
<span>Ende: {endTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
{/* Phase Breakdown */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-slate-400" />
Phasen-Analyse
</h3>
<div className="space-y-4">
{session.phases.map((phase) => {
const plannedSeconds = phase.duration * 60
const actualSeconds = phase.actualTime
const diff = actualSeconds - plannedSeconds
const diffMinutes = Math.round(diff / 60)
const percentage = Math.min((actualSeconds / plannedSeconds) * 100, 150)
return (
<div key={phase.phase} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PHASE_COLORS[phase.phase].hex }}
/>
<span className="font-medium text-slate-700">
{PHASE_DISPLAY_NAMES[phase.phase]}
</span>
</div>
<div className="flex items-center gap-3 text-slate-500">
<span>{Math.round(actualSeconds / 60)} / {phase.duration} Min</span>
<span className={`
px-2 py-0.5 rounded text-xs font-medium
${diff > 60 ? 'bg-amber-100 text-amber-700' : diff < -60 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}
`}>
{diffMinutes > 0 ? '+' : ''}{diffMinutes} Min
</span>
</div>
</div>
{/* Progress Bar */}
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(percentage, 100)}%`,
backgroundColor: percentage > 100
? '#f59e0b' // amber for overtime
: PHASE_COLORS[phase.phase].hex,
}}
/>
</div>
</div>
)
})}
</div>
</div>
</div>
)}
{activeTab === 'homework' && (
<HomeworkSection
homeworkList={session.homeworkList}
onAdd={onAddHomework}
onRemove={onRemoveHomework}
/>
)}
{activeTab === 'reflection' && (
<ReflectionSection
reflection={session.reflection}
onSave={onSaveReflection}
/>
)}
{/* Start New Lesson Button */}
<div className="pt-4">
<button
onClick={onStartNew}
className="w-full py-4 px-6 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors flex items-center justify-center gap-2"
>
<RefreshCw className="w-5 h-5" />
Neue Stunde starten
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,269 @@
'use client'
import { useState } from 'react'
import { Play, Clock, BookOpen, Users, ChevronDown, Info } from 'lucide-react'
import { LessonTemplate, PhaseDurations, Class } from '@/lib/companion/types'
import {
SYSTEM_TEMPLATES,
DEFAULT_PHASE_DURATIONS,
PHASE_DISPLAY_NAMES,
PHASE_ORDER,
calculateTotalDuration,
formatMinutes,
} from '@/lib/companion/constants'
interface LessonStartFormProps {
onStart: (data: {
classId: string
subject: string
topic?: string
templateId?: string
}) => void
loading?: boolean
availableClasses?: Class[]
}
// Mock classes for development
const MOCK_CLASSES: Class[] = [
{ id: 'c1', name: '9a', grade: '9', studentCount: 28 },
{ id: 'c2', name: '9b', grade: '9', studentCount: 26 },
{ id: 'c3', name: '10a', grade: '10', studentCount: 24 },
{ id: 'c4', name: 'Deutsch LK', grade: 'Q1', studentCount: 18 },
{ id: 'c5', name: 'Mathe GK', grade: 'Q2', studentCount: 22 },
]
const SUBJECTS = [
'Deutsch',
'Mathematik',
'Englisch',
'Biologie',
'Physik',
'Chemie',
'Geschichte',
'Geographie',
'Politik',
'Kunst',
'Musik',
'Sport',
'Informatik',
'Sonstiges',
]
export function LessonStartForm({
onStart,
loading,
availableClasses = MOCK_CLASSES,
}: LessonStartFormProps) {
const [selectedClass, setSelectedClass] = useState('')
const [selectedSubject, setSelectedSubject] = useState('')
const [topic, setTopic] = useState('')
const [selectedTemplate, setSelectedTemplate] = useState<LessonTemplate | null>(
SYSTEM_TEMPLATES[0] as LessonTemplate
)
const [showTemplateDetails, setShowTemplateDetails] = useState(false)
const totalDuration = selectedTemplate
? calculateTotalDuration(selectedTemplate.durations)
: calculateTotalDuration(DEFAULT_PHASE_DURATIONS)
const canStart = selectedClass && selectedSubject
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!canStart) return
onStart({
classId: selectedClass,
subject: selectedSubject,
topic: topic || undefined,
templateId: selectedTemplate?.templateId,
})
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-blue-100 rounded-xl">
<Play className="w-6 h-6 text-blue-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-slate-900">Neue Stunde starten</h2>
<p className="text-sm text-slate-500">
Waehlen Sie Klasse, Fach und Template
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Class Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<Users className="w-4 h-4 inline mr-2" />
Klasse *
</label>
<select
value={selectedClass}
onChange={(e) => setSelectedClass(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
required
>
<option value="">Klasse auswaehlen...</option>
{availableClasses.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name} ({cls.studentCount} Schueler)
</option>
))}
</select>
</div>
{/* Subject Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<BookOpen className="w-4 h-4 inline mr-2" />
Fach *
</label>
<select
value={selectedSubject}
onChange={(e) => setSelectedSubject(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
required
>
<option value="">Fach auswaehlen...</option>
{SUBJECTS.map((subject) => (
<option key={subject} value={subject}>
{subject}
</option>
))}
</select>
</div>
{/* Topic (Optional) */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Thema (optional)
</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="z.B. Quadratische Funktionen, Gedichtanalyse..."
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Template Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<Clock className="w-4 h-4 inline mr-2" />
Template
</label>
<div className="space-y-2">
{SYSTEM_TEMPLATES.map((template) => {
const tpl = template as LessonTemplate
const isSelected = selectedTemplate?.templateId === tpl.templateId
const total = calculateTotalDuration(tpl.durations)
return (
<button
key={tpl.templateId}
type="button"
onClick={() => setSelectedTemplate(tpl)}
className={`
w-full p-4 rounded-xl border text-left transition-all
${isSelected
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/20'
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}
`}
>
<div className="flex items-center justify-between">
<div>
<p className={`font-medium ${isSelected ? 'text-blue-900' : 'text-slate-900'}`}>
{tpl.name}
</p>
<p className="text-sm text-slate-500">{tpl.description}</p>
</div>
<span className={`text-sm font-medium ${isSelected ? 'text-blue-600' : 'text-slate-500'}`}>
{formatMinutes(total)}
</span>
</div>
</button>
)
})}
</div>
{/* Template Details Toggle */}
{selectedTemplate && (
<button
type="button"
onClick={() => setShowTemplateDetails(!showTemplateDetails)}
className="mt-3 flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
>
<Info className="w-4 h-4" />
Phasendauern anzeigen
<ChevronDown className={`w-4 h-4 transition-transform ${showTemplateDetails ? 'rotate-180' : ''}`} />
</button>
)}
{/* Template Details */}
{showTemplateDetails && selectedTemplate && (
<div className="mt-3 p-4 bg-slate-50 rounded-xl">
<div className="grid grid-cols-5 gap-2">
{PHASE_ORDER.map((phaseId) => (
<div key={phaseId} className="text-center">
<p className="text-xs text-slate-500">{PHASE_DISPLAY_NAMES[phaseId]}</p>
<p className="font-medium text-slate-900">
{selectedTemplate.durations[phaseId]} Min
</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Summary & Start Button */}
<div className="pt-4 border-t border-slate-200">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-slate-600">
Gesamtdauer: <span className="font-semibold">{formatMinutes(totalDuration)}</span>
</div>
{selectedClass && (
<div className="text-sm text-slate-600">
Klasse: <span className="font-semibold">
{availableClasses.find((c) => c.id === selectedClass)?.name}
</span>
</div>
)}
</div>
<button
type="submit"
disabled={!canStart || loading}
className={`
w-full py-4 px-6 rounded-xl font-semibold text-lg
flex items-center justify-center gap-3
transition-all duration-200
${canStart && !loading
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-lg shadow-blue-500/25'
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
}
`}
>
{loading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Stunde wird gestartet...
</>
) : (
<>
<Play className="w-5 h-5" />
Stunde starten
</>
)}
</button>
</div>
</form>
</div>
)
}

View File

@@ -0,0 +1,194 @@
'use client'
import { Plus, Pause, Play, SkipForward, Square, Clock } from 'lucide-react'
interface QuickActionsBarProps {
onExtend: (minutes: number) => void
onPause: () => void
onResume: () => void
onSkip: () => void
onEnd: () => void
isPaused: boolean
isLastPhase: boolean
disabled?: boolean
}
export function QuickActionsBar({
onExtend,
onPause,
onResume,
onSkip,
onEnd,
isPaused,
isLastPhase,
disabled,
}: QuickActionsBarProps) {
return (
<div
className="flex items-center justify-center gap-3 p-4 bg-white border border-slate-200 rounded-xl"
role="toolbar"
aria-label="Steuerung"
>
{/* Extend +5 Min */}
<button
onClick={() => onExtend(5)}
disabled={disabled || isPaused}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled || isPaused
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-50 text-blue-700 hover:bg-blue-100 active:scale-95'
}
`}
title="+5 Minuten (E)"
aria-keyshortcuts="e"
aria-label="5 Minuten verlaengern"
>
<Plus className="w-5 h-5" />
<span>5 Min</span>
</button>
{/* Pause / Resume */}
<button
onClick={isPaused ? onResume : onPause}
disabled={disabled}
className={`
flex items-center gap-2 px-6 py-3 rounded-xl
font-semibold transition-all duration-200
min-w-[52px] min-h-[52px] justify-center
${disabled
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: isPaused
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-500/25 active:scale-95'
: 'bg-amber-500 text-white hover:bg-amber-600 shadow-lg shadow-amber-500/25 active:scale-95'
}
`}
title={isPaused ? 'Fortsetzen (Leertaste)' : 'Pausieren (Leertaste)'}
aria-keyshortcuts="Space"
aria-label={isPaused ? 'Stunde fortsetzen' : 'Stunde pausieren'}
>
{isPaused ? (
<>
<Play className="w-5 h-5" />
<span>Fortsetzen</span>
</>
) : (
<>
<Pause className="w-5 h-5" />
<span>Pause</span>
</>
)}
</button>
{/* Skip Phase / End Lesson */}
{isLastPhase ? (
<button
onClick={onEnd}
disabled={disabled}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-red-50 text-red-700 hover:bg-red-100 active:scale-95'
}
`}
title="Stunde beenden"
aria-label="Stunde beenden"
>
<Square className="w-5 h-5" />
<span>Beenden</span>
</button>
) : (
<button
onClick={onSkip}
disabled={disabled || isPaused}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled || isPaused
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 active:scale-95'
}
`}
title="Naechste Phase (N)"
aria-keyshortcuts="n"
aria-label="Zur naechsten Phase springen"
>
<SkipForward className="w-5 h-5" />
<span>Weiter</span>
</button>
)}
</div>
)
}
/**
* Compact version for mobile or sidebar
*/
export function QuickActionsCompact({
onExtend,
onPause,
onResume,
onSkip,
isPaused,
isLastPhase,
disabled,
}: Omit<QuickActionsBarProps, 'onEnd'>) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => onExtend(5)}
disabled={disabled || isPaused}
className={`
p-2 rounded-lg transition-all
${disabled || isPaused
? 'text-slate-300'
: 'text-blue-600 hover:bg-blue-50'
}
`}
title="+5 Min"
>
<Clock className="w-5 h-5" />
</button>
<button
onClick={isPaused ? onResume : onPause}
disabled={disabled}
className={`
p-2 rounded-lg transition-all
${disabled
? 'text-slate-300'
: isPaused
? 'text-green-600 hover:bg-green-50'
: 'text-amber-600 hover:bg-amber-50'
}
`}
title={isPaused ? 'Fortsetzen' : 'Pausieren'}
>
{isPaused ? <Play className="w-5 h-5" /> : <Pause className="w-5 h-5" />}
</button>
{!isLastPhase && (
<button
onClick={onSkip}
disabled={disabled || isPaused}
className={`
p-2 rounded-lg transition-all
${disabled || isPaused
? 'text-slate-300'
: 'text-slate-600 hover:bg-slate-50'
}
`}
title="Naechste Phase"
>
<SkipForward className="w-5 h-5" />
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,146 @@
'use client'
import { useState, useEffect } from 'react'
import { Star, Save, CheckCircle } from 'lucide-react'
import { LessonReflection } from '@/lib/companion/types'
interface ReflectionSectionProps {
reflection?: LessonReflection
onSave: (rating: number, notes: string, nextSteps: string) => void
}
export function ReflectionSection({ reflection, onSave }: ReflectionSectionProps) {
const [rating, setRating] = useState(reflection?.rating || 0)
const [notes, setNotes] = useState(reflection?.notes || '')
const [nextSteps, setNextSteps] = useState(reflection?.nextSteps || '')
const [hoverRating, setHoverRating] = useState(0)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (reflection) {
setRating(reflection.rating)
setNotes(reflection.notes)
setNextSteps(reflection.nextSteps)
}
}, [reflection])
const handleSave = () => {
if (rating === 0) return
onSave(rating, notes, nextSteps)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const ratingLabels = [
'', // 0
'Verbesserungsbedarf',
'Okay',
'Gut',
'Sehr gut',
'Ausgezeichnet',
]
return (
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-6">
{/* Star Rating */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Wie lief die Stunde?
</label>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((star) => {
const isFilled = star <= (hoverRating || rating)
return (
<button
key={star}
type="button"
onClick={() => setRating(star)}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
className="p-1 transition-transform hover:scale-110"
aria-label={`${star} Stern${star > 1 ? 'e' : ''}`}
>
<Star
className={`w-8 h-8 ${
isFilled
? 'fill-amber-400 text-amber-400'
: 'text-slate-300'
}`}
/>
</button>
)
})}
{(hoverRating || rating) > 0 && (
<span className="ml-3 text-sm text-slate-600">
{ratingLabels[hoverRating || rating]}
</span>
)}
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Notizen zur Stunde
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Was lief gut? Was koennte besser laufen? Besondere Vorkommnisse..."
rows={4}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Next Steps */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Naechste Schritte
</label>
<textarea
value={nextSteps}
onChange={(e) => setNextSteps(e.target.value)}
placeholder="Was muss fuer die naechste Stunde vorbereitet werden? Follow-ups..."
rows={3}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Save Button */}
<button
onClick={handleSave}
disabled={rating === 0}
className={`
w-full py-3 px-6 rounded-xl font-semibold
flex items-center justify-center gap-2
transition-all duration-200
${saved
? 'bg-green-600 text-white'
: rating === 0
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{saved ? (
<>
<CheckCircle className="w-5 h-5" />
Gespeichert!
</>
) : (
<>
<Save className="w-5 h-5" />
Reflexion speichern
</>
)}
</button>
{/* Previous Reflection Info */}
{reflection?.savedAt && (
<p className="text-center text-sm text-slate-400">
Zuletzt gespeichert: {new Date(reflection.savedAt).toLocaleString('de-DE')}
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,220 @@
'use client'
import { Pause, Play } from 'lucide-react'
import { TimerColorStatus } from '@/lib/companion/types'
import {
PIE_TIMER_RADIUS,
PIE_TIMER_CIRCUMFERENCE,
PIE_TIMER_STROKE_WIDTH,
PIE_TIMER_SIZE,
TIMER_COLOR_CLASSES,
TIMER_BG_COLORS,
formatTime,
} from '@/lib/companion/constants'
interface VisualPieTimerProps {
progress: number // 0-1 (how much time has elapsed)
remainingSeconds: number
totalSeconds: number
colorStatus: TimerColorStatus
isPaused: boolean
currentPhaseName: string
phaseColor: string
onTogglePause?: () => void
size?: 'sm' | 'md' | 'lg'
}
const sizeConfig = {
sm: { outer: 120, viewBox: 100, radius: 38, stroke: 6, fontSize: 'text-lg' },
md: { outer: 180, viewBox: 100, radius: 40, stroke: 7, fontSize: 'text-2xl' },
lg: { outer: 240, viewBox: 100, radius: 42, stroke: 8, fontSize: 'text-4xl' },
}
export function VisualPieTimer({
progress,
remainingSeconds,
totalSeconds,
colorStatus,
isPaused,
currentPhaseName,
phaseColor,
onTogglePause,
size = 'lg',
}: VisualPieTimerProps) {
const config = sizeConfig[size]
const circumference = 2 * Math.PI * config.radius
// Calculate stroke-dashoffset for progress
// Progress goes from 0 (full) to 1 (empty), so offset decreases as time passes
const strokeDashoffset = circumference * (1 - progress)
// For overtime, show a pulsing full circle
const isOvertime = colorStatus === 'overtime'
const displayTime = formatTime(remainingSeconds)
// Get color classes based on status
const colorClasses = TIMER_COLOR_CLASSES[colorStatus]
const bgColorClass = TIMER_BG_COLORS[colorStatus]
return (
<div className="flex flex-col items-center">
{/* Timer Circle */}
<div
className={`relative ${bgColorClass} rounded-full p-4 transition-colors duration-300`}
style={{ width: config.outer, height: config.outer }}
>
<svg
width="100%"
height="100%"
viewBox={`0 0 ${config.viewBox} ${config.viewBox}`}
className="transform -rotate-90"
>
{/* Background circle */}
<circle
cx={config.viewBox / 2}
cy={config.viewBox / 2}
r={config.radius}
fill="none"
stroke="currentColor"
strokeWidth={config.stroke}
className="text-slate-200"
/>
{/* Progress circle */}
<circle
cx={config.viewBox / 2}
cy={config.viewBox / 2}
r={config.radius}
fill="none"
stroke={isOvertime ? '#dc2626' : phaseColor}
strokeWidth={config.stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={isOvertime ? 0 : strokeDashoffset}
className={`transition-all duration-100 ${isOvertime ? 'animate-pulse' : ''}`}
/>
</svg>
{/* Center Content */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
{/* Time Display */}
<span
className={`
font-mono font-bold ${config.fontSize}
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
`}
>
{displayTime}
</span>
{/* Phase Name */}
<span className="text-sm text-slate-500 mt-1">
{currentPhaseName}
</span>
{/* Paused Indicator */}
{isPaused && (
<span className="text-xs text-amber-600 font-medium mt-1 flex items-center gap-1">
<Pause className="w-3 h-3" />
Pausiert
</span>
)}
{/* Overtime Badge */}
{isOvertime && (
<span className="absolute -bottom-2 px-2 py-0.5 bg-red-600 text-white text-xs font-bold rounded-full">
+{Math.abs(Math.floor(remainingSeconds / 60))} Min
</span>
)}
</div>
{/* Pause/Play Button (overlay) */}
{onTogglePause && (
<button
onClick={onTogglePause}
className={`
absolute inset-0 rounded-full
flex items-center justify-center
opacity-0 hover:opacity-100
bg-black/20 backdrop-blur-sm
transition-opacity duration-200
`}
aria-label={isPaused ? 'Fortsetzen' : 'Pausieren'}
>
{isPaused ? (
<Play className="w-12 h-12 text-white" />
) : (
<Pause className="w-12 h-12 text-white" />
)}
</button>
)}
</div>
{/* Status Text */}
<div className="mt-4 text-center">
{isOvertime ? (
<p className="text-red-600 font-semibold animate-pulse">
Ueberzogen - Zeit fuer die naechste Phase!
</p>
) : colorStatus === 'critical' ? (
<p className="text-red-500 font-medium">
Weniger als 2 Minuten verbleibend
</p>
) : colorStatus === 'warning' ? (
<p className="text-amber-500">
Weniger als 5 Minuten verbleibend
</p>
) : null}
</div>
</div>
)
}
/**
* Compact timer for header/toolbar
*/
export function CompactTimer({
remainingSeconds,
colorStatus,
isPaused,
phaseName,
phaseColor,
}: {
remainingSeconds: number
colorStatus: TimerColorStatus
isPaused: boolean
phaseName: string
phaseColor: string
}) {
const isOvertime = colorStatus === 'overtime'
return (
<div className="flex items-center gap-3 px-4 py-2 bg-white border border-slate-200 rounded-xl">
{/* Phase indicator */}
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: phaseColor }}
/>
{/* Phase name */}
<span className="text-sm font-medium text-slate-600">{phaseName}</span>
{/* Time */}
<span
className={`
font-mono font-bold
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
`}
>
{formatTime(remainingSeconds)}
</span>
{/* Paused badge */}
{isPaused && (
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
Pausiert
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,201 @@
'use client'
import { useState } from 'react'
import { X, MessageSquare, Bug, Lightbulb, Send, CheckCircle } from 'lucide-react'
import { FeedbackType } from '@/lib/companion/types'
interface FeedbackModalProps {
isOpen: boolean
onClose: () => void
onSubmit: (type: FeedbackType, title: string, description: string) => Promise<void>
}
const feedbackTypes: { id: FeedbackType; label: string; icon: typeof Bug; color: string }[] = [
{ id: 'bug', label: 'Bug melden', icon: Bug, color: 'text-red-600 bg-red-50' },
{ id: 'feature', label: 'Feature-Wunsch', icon: Lightbulb, color: 'text-amber-600 bg-amber-50' },
{ id: 'feedback', label: 'Allgemeines Feedback', icon: MessageSquare, color: 'text-blue-600 bg-blue-50' },
]
export function FeedbackModal({ isOpen, onClose, onSubmit }: FeedbackModalProps) {
const [type, setType] = useState<FeedbackType>('feedback')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
if (!isOpen) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !description.trim()) return
setIsSubmitting(true)
try {
await onSubmit(type, title.trim(), description.trim())
setIsSuccess(true)
setTimeout(() => {
setIsSuccess(false)
setTitle('')
setDescription('')
setType('feedback')
onClose()
}, 2000)
} catch (error) {
console.error('Failed to submit feedback:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-xl">
<MessageSquare className="w-5 h-5 text-blue-600" />
</div>
<h2 className="text-xl font-semibold text-slate-900">Feedback senden</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Success State */}
{isSuccess ? (
<div className="p-12 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-semibold text-slate-900 mb-2">Vielen Dank!</h3>
<p className="text-slate-600">Ihr Feedback wurde erfolgreich gesendet.</p>
</div>
) : (
<form onSubmit={handleSubmit}>
{/* Content */}
<div className="p-6 space-y-6">
{/* Feedback Type */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Art des Feedbacks
</label>
<div className="grid grid-cols-3 gap-3">
{feedbackTypes.map((ft) => (
<button
key={ft.id}
type="button"
onClick={() => setType(ft.id)}
className={`
p-4 rounded-xl border-2 text-center transition-all
${type === ft.id
? 'border-blue-500 bg-blue-50'
: 'border-slate-200 hover:border-slate-300'
}
`}
>
<div className={`w-10 h-10 rounded-lg ${ft.color} flex items-center justify-center mx-auto mb-2`}>
<ft.icon className="w-5 h-5" />
</div>
<span className={`text-sm font-medium ${type === ft.id ? 'text-blue-700' : 'text-slate-700'}`}>
{ft.label}
</span>
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Titel *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={
type === 'bug'
? 'z.B. Timer stoppt nach Pause nicht mehr'
: type === 'feature'
? 'z.B. Materialien an Stunde anhaengen'
: 'z.B. Super nuetzliches Tool!'
}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Beschreibung *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
type === 'bug'
? 'Bitte beschreiben Sie den Fehler moeglichst genau. Was haben Sie gemacht? Was ist passiert? Was haetten Sie erwartet?'
: type === 'feature'
? 'Beschreiben Sie die gewuenschte Funktion. Warum waere sie hilfreich?'
: 'Teilen Sie uns Ihre Gedanken mit...'
}
rows={5}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
required
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-slate-200 bg-slate-50">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!title.trim() || !description.trim() || isSubmitting}
className={`
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
transition-all duration-200
${!title.trim() || !description.trim() || isSubmitting
? 'bg-slate-200 text-slate-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{isSubmitting ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Senden...
</>
) : (
<>
<Send className="w-4 h-4" />
Absenden
</>
)}
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,280 @@
'use client'
import { useState } from 'react'
import { ChevronRight, ChevronLeft, Check, GraduationCap, Settings, Timer } from 'lucide-react'
interface OnboardingModalProps {
isOpen: boolean
onClose: () => void
onComplete: (data: { state?: string; schoolType?: string }) => void
}
const STATES = [
'Baden-Wuerttemberg',
'Bayern',
'Berlin',
'Brandenburg',
'Bremen',
'Hamburg',
'Hessen',
'Mecklenburg-Vorpommern',
'Niedersachsen',
'Nordrhein-Westfalen',
'Rheinland-Pfalz',
'Saarland',
'Sachsen',
'Sachsen-Anhalt',
'Schleswig-Holstein',
'Thueringen',
]
const SCHOOL_TYPES = [
'Grundschule',
'Hauptschule',
'Realschule',
'Gymnasium',
'Gesamtschule',
'Berufsschule',
'Foerderschule',
'Andere',
]
interface Step {
id: number
title: string
description: string
icon: typeof GraduationCap
}
const steps: Step[] = [
{
id: 1,
title: 'Willkommen',
description: 'Der Companion hilft Ihnen bei der Unterrichtsplanung und -durchfuehrung.',
icon: GraduationCap,
},
{
id: 2,
title: 'Ihre Schule',
description: 'Waehlen Sie Ihr Bundesland und Ihre Schulform.',
icon: Settings,
},
{
id: 3,
title: 'Bereit!',
description: 'Sie koennen jetzt mit dem Lesson-Modus starten.',
icon: Timer,
},
]
export function OnboardingModal({ isOpen, onClose, onComplete }: OnboardingModalProps) {
const [currentStep, setCurrentStep] = useState(1)
const [selectedState, setSelectedState] = useState('')
const [selectedSchoolType, setSelectedSchoolType] = useState('')
if (!isOpen) return null
const canProceed = () => {
if (currentStep === 2) {
return selectedState !== '' && selectedSchoolType !== ''
}
return true
}
const handleNext = () => {
if (currentStep < 3) {
setCurrentStep(currentStep + 1)
} else {
onComplete({
state: selectedState,
schoolType: selectedSchoolType,
})
}
}
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const currentStepData = steps[currentStep - 1]
const Icon = currentStepData.icon
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 overflow-hidden">
{/* Progress Bar */}
<div className="h-1 bg-slate-100">
<div
className="h-full bg-blue-600 transition-all duration-300"
style={{ width: `${(currentStep / 3) * 100}%` }}
/>
</div>
{/* Content */}
<div className="p-8">
{/* Step Indicator */}
<div className="flex items-center justify-center gap-2 mb-8">
{steps.map((step) => (
<div
key={step.id}
className={`
w-3 h-3 rounded-full transition-all
${step.id === currentStep
? 'bg-blue-600 scale-125'
: step.id < currentStep
? 'bg-blue-600'
: 'bg-slate-200'
}
`}
/>
))}
</div>
{/* Icon */}
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Icon className="w-8 h-8 text-blue-600" />
</div>
{/* Title & Description */}
<h2 className="text-2xl font-bold text-slate-900 text-center mb-2">
{currentStepData.title}
</h2>
<p className="text-slate-600 text-center mb-8">
{currentStepData.description}
</p>
{/* Step Content */}
{currentStep === 1 && (
<div className="space-y-4 text-center">
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-blue-50 rounded-xl">
<div className="text-2xl mb-1">5</div>
<div className="text-xs text-slate-600">Phasen</div>
</div>
<div className="p-4 bg-green-50 rounded-xl">
<div className="text-2xl mb-1">45</div>
<div className="text-xs text-slate-600">Minuten</div>
</div>
<div className="p-4 bg-purple-50 rounded-xl">
<div className="text-2xl mb-1"></div>
<div className="text-xs text-slate-600">Flexibel</div>
</div>
</div>
<p className="text-sm text-slate-500 mt-4">
Einstieg Erarbeitung Sicherung Transfer Reflexion
</p>
</div>
)}
{currentStep === 2 && (
<div className="space-y-4">
{/* State Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Bundesland
</label>
<select
value={selectedState}
onChange={(e) => setSelectedState(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Bitte waehlen...</option>
{STATES.map((state) => (
<option key={state} value={state}>
{state}
</option>
))}
</select>
</div>
{/* School Type Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schulform
</label>
<select
value={selectedSchoolType}
onChange={(e) => setSelectedSchoolType(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Bitte waehlen...</option>
{SCHOOL_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
</div>
)}
{currentStep === 3 && (
<div className="text-center space-y-4">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<Check className="w-10 h-10 text-green-600" />
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-sm text-slate-600">
<strong>Bundesland:</strong> {selectedState || 'Nicht angegeben'}
</p>
<p className="text-sm text-slate-600">
<strong>Schulform:</strong> {selectedSchoolType || 'Nicht angegeben'}
</p>
</div>
<p className="text-sm text-slate-500">
Sie koennen diese Einstellungen jederzeit aendern.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
<button
onClick={currentStep === 1 ? onClose : handleBack}
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
{currentStep === 1 ? (
'Ueberspringen'
) : (
<>
<ChevronLeft className="w-4 h-4" />
Zurueck
</>
)}
</button>
<button
onClick={handleNext}
disabled={!canProceed()}
className={`
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
transition-all duration-200
${canProceed()
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
}
`}
>
{currentStep === 3 ? (
<>
<Check className="w-4 h-4" />
Fertig
</>
) : (
<>
Weiter
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,248 @@
'use client'
import { useState, useEffect } from 'react'
import { X, Settings, Save, RotateCcw } from 'lucide-react'
import { TeacherSettings, PhaseDurations } from '@/lib/companion/types'
import {
DEFAULT_TEACHER_SETTINGS,
DEFAULT_PHASE_DURATIONS,
PHASE_ORDER,
PHASE_DISPLAY_NAMES,
PHASE_COLORS,
calculateTotalDuration,
} from '@/lib/companion/constants'
interface SettingsModalProps {
isOpen: boolean
onClose: () => void
settings: TeacherSettings
onSave: (settings: TeacherSettings) => void
}
export function SettingsModal({
isOpen,
onClose,
settings,
onSave,
}: SettingsModalProps) {
const [localSettings, setLocalSettings] = useState<TeacherSettings>(settings)
const [durations, setDurations] = useState<PhaseDurations>(settings.defaultPhaseDurations)
useEffect(() => {
setLocalSettings(settings)
setDurations(settings.defaultPhaseDurations)
}, [settings])
if (!isOpen) return null
const totalDuration = calculateTotalDuration(durations)
const handleDurationChange = (phase: keyof PhaseDurations, value: number) => {
const newDurations = { ...durations, [phase]: Math.max(1, Math.min(60, value)) }
setDurations(newDurations)
}
const handleReset = () => {
setDurations(DEFAULT_PHASE_DURATIONS)
setLocalSettings(DEFAULT_TEACHER_SETTINGS)
}
const handleSave = () => {
const newSettings: TeacherSettings = {
...localSettings,
defaultPhaseDurations: durations,
}
onSave(newSettings)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-100 rounded-xl">
<Settings className="w-5 h-5 text-slate-600" />
</div>
<h2 className="text-xl font-semibold text-slate-900">Einstellungen</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6 overflow-y-auto max-h-[60vh]">
{/* Phase Durations */}
<div>
<h3 className="text-sm font-medium text-slate-700 mb-4">
Standard-Phasendauern (Minuten)
</h3>
<div className="space-y-4">
{PHASE_ORDER.map((phase) => (
<div key={phase} className="flex items-center gap-4">
<div className="flex items-center gap-2 w-32">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PHASE_COLORS[phase].hex }}
/>
<span className="text-sm text-slate-700">
{PHASE_DISPLAY_NAMES[phase]}
</span>
</div>
<input
type="number"
min={1}
max={60}
value={durations[phase]}
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value) || 1)}
className="w-20 px-3 py-2 border border-slate-200 rounded-lg text-center focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<input
type="range"
min={1}
max={45}
value={durations[phase]}
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value))}
className="flex-1"
style={{
accentColor: PHASE_COLORS[phase].hex,
}}
/>
</div>
))}
</div>
<div className="mt-4 p-3 bg-slate-50 rounded-xl flex items-center justify-between">
<span className="text-sm text-slate-600">Gesamtdauer:</span>
<span className="font-semibold text-slate-900">{totalDuration} Minuten</span>
</div>
</div>
{/* Other Settings */}
<div className="space-y-4 pt-4 border-t border-slate-200">
<h3 className="text-sm font-medium text-slate-700 mb-4">
Weitere Einstellungen
</h3>
{/* Auto Advance */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Automatischer Phasenwechsel
</span>
<p className="text-xs text-slate-500">
Phasen automatisch wechseln wenn Zeit abgelaufen
</p>
</div>
<input
type="checkbox"
checked={localSettings.autoAdvancePhases}
onChange={(e) =>
setLocalSettings({ ...localSettings, autoAdvancePhases: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* Sound Notifications */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Ton-Benachrichtigungen
</span>
<p className="text-xs text-slate-500">
Signalton bei Phasenende und Warnungen
</p>
</div>
<input
type="checkbox"
checked={localSettings.soundNotifications}
onChange={(e) =>
setLocalSettings({ ...localSettings, soundNotifications: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* Keyboard Shortcuts */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Tastaturkuerzel anzeigen
</span>
<p className="text-xs text-slate-500">
Hinweise zu Tastaturkuerzeln einblenden
</p>
</div>
<input
type="checkbox"
checked={localSettings.showKeyboardShortcuts}
onChange={(e) =>
setLocalSettings({ ...localSettings, showKeyboardShortcuts: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* High Contrast */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Hoher Kontrast
</span>
<p className="text-xs text-slate-500">
Bessere Sichtbarkeit durch erhoehten Kontrast
</p>
</div>
<input
type="checkbox"
checked={localSettings.highContrastMode}
onChange={(e) =>
setLocalSettings({ ...localSettings, highContrastMode: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
<RotateCcw className="w-4 h-4" />
Zuruecksetzen
</button>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
<Save className="w-4 h-4" />
Speichern
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -4,11 +4,15 @@
* GridOverlay Component
*
* SVG overlay for displaying detected OCR grid structure on document images.
* Shows recognized (green), problematic (orange), manual (blue), and empty (transparent) cells.
* Supports click-to-edit for problematic cells.
* Features:
* - Cell status visualization (recognized/problematic/manual/empty)
* - 1mm grid overlay for A4 pages (210x297mm)
* - Text at original bounding-box positions
* - Editable text (contentEditable) at original positions
* - Click-to-edit for cells
*/
import { useCallback } from 'react'
import { useCallback, useState } from 'react'
import { cn } from '@/lib/utils'
export type CellStatus = 'empty' | 'recognized' | 'problematic' | 'manual'
@@ -24,6 +28,10 @@ export interface GridCell {
confidence: number
status: CellStatus
column_type?: 'english' | 'german' | 'example' | 'unknown'
x_mm?: number
y_mm?: number
width_mm?: number
height_mm?: number
}
export interface GridData {
@@ -42,57 +50,198 @@ export interface GridData {
total: number
coverage: number
}
page_dimensions?: {
width_mm: number
height_mm: number
format: string
}
source?: string
}
interface GridOverlayProps {
grid: GridData
imageUrl?: string
onCellClick?: (cell: GridCell) => void
onCellTextChange?: (cell: GridCell, newText: string) => void
selectedCell?: GridCell | null
showEmpty?: boolean
showLabels?: boolean
showNumbers?: boolean // Show block numbers in cells
highlightedBlockNumber?: number | null // Highlight specific block
showNumbers?: boolean
showTextLabels?: boolean
showMmGrid?: boolean
showTextAtPosition?: boolean
editableText?: boolean
highlightedBlockNumber?: number | null
className?: string
}
// Status colors
const STATUS_COLORS = {
recognized: {
fill: 'rgba(34, 197, 94, 0.2)', // green-500 with opacity
stroke: '#22c55e', // green-500
fill: 'rgba(34, 197, 94, 0.2)',
stroke: '#22c55e',
hoverFill: 'rgba(34, 197, 94, 0.3)',
},
problematic: {
fill: 'rgba(249, 115, 22, 0.3)', // orange-500 with opacity
stroke: '#f97316', // orange-500
fill: 'rgba(249, 115, 22, 0.3)',
stroke: '#f97316',
hoverFill: 'rgba(249, 115, 22, 0.4)',
},
manual: {
fill: 'rgba(59, 130, 246, 0.2)', // blue-500 with opacity
stroke: '#3b82f6', // blue-500
fill: 'rgba(59, 130, 246, 0.2)',
stroke: '#3b82f6',
hoverFill: 'rgba(59, 130, 246, 0.3)',
},
empty: {
fill: 'transparent',
stroke: 'rgba(148, 163, 184, 0.3)', // slate-400 with opacity
stroke: 'rgba(148, 163, 184, 0.3)',
hoverFill: 'rgba(148, 163, 184, 0.1)',
},
}
// A4 dimensions for mm grid
const A4_WIDTH_MM = 210
const A4_HEIGHT_MM = 297
// Helper to calculate block number (1-indexed, row-by-row)
export function getCellBlockNumber(cell: GridCell, grid: GridData): number {
return cell.row * grid.columns + cell.col + 1
}
/**
* 1mm Grid SVG Lines for A4 format.
* Renders inside a viewBox="0 0 100 100" (percentage-based).
*/
function MmGridLines() {
const lines: React.ReactNode[] = []
// Vertical lines: 210 lines for 210mm
for (let mm = 0; mm <= A4_WIDTH_MM; mm++) {
const x = (mm / A4_WIDTH_MM) * 100
const isCm = mm % 10 === 0
lines.push(
<line
key={`v-${mm}`}
x1={x}
y1={0}
x2={x}
y2={100}
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
strokeWidth={isCm ? 0.08 : 0.03}
/>
)
}
// Horizontal lines: 297 lines for 297mm
for (let mm = 0; mm <= A4_HEIGHT_MM; mm++) {
const y = (mm / A4_HEIGHT_MM) * 100
const isCm = mm % 10 === 0
lines.push(
<line
key={`h-${mm}`}
x1={0}
y1={y}
x2={100}
y2={y}
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
strokeWidth={isCm ? 0.08 : 0.03}
/>
)
}
return <g style={{ pointerEvents: 'none' }}>{lines}</g>
}
/**
* Positioned text overlay using absolute-positioned HTML divs.
* Each cell's text appears at its bounding-box position with matching font size.
*/
function PositionedTextLayer({
cells,
editable,
onTextChange,
}: {
cells: GridCell[]
editable: boolean
onTextChange?: (cell: GridCell, text: string) => void
}) {
const [hoveredCell, setHoveredCell] = useState<string | null>(null)
return (
<div className="absolute inset-0" style={{ pointerEvents: editable ? 'auto' : 'none' }}>
{cells.map((cell) => {
if (cell.status === 'empty' || !cell.text) return null
const cellKey = `pos-${cell.row}-${cell.col}`
const isHovered = hoveredCell === cellKey
// Estimate font size from cell height: height_pct maps to roughly pt size
// A4 at 100% = 297mm height. Cell height in % * 297mm / 100 = height_mm
// Font size ~= height_mm * 2.2 (roughly matching print)
const heightMm = cell.height_mm ?? (cell.height / 100 * A4_HEIGHT_MM)
const fontSizePt = Math.max(6, Math.min(18, heightMm * 2.2))
return (
<div
key={cellKey}
className={cn(
'absolute overflow-hidden transition-colors duration-100',
editable && 'cursor-text hover:bg-yellow-100/40',
isHovered && !editable && 'bg-blue-100/30',
)}
style={{
left: `${cell.x}%`,
top: `${cell.y}%`,
width: `${cell.width}%`,
height: `${cell.height}%`,
fontSize: `${fontSizePt}pt`,
fontFamily: '"Georgia", "Times New Roman", serif',
lineHeight: 1.1,
color: cell.status === 'manual' ? '#1e40af' : '#1a1a1a',
padding: '0 1px',
display: 'flex',
alignItems: 'center',
}}
onMouseEnter={() => setHoveredCell(cellKey)}
onMouseLeave={() => setHoveredCell(null)}
>
{editable ? (
<span
contentEditable
suppressContentEditableWarning
className="outline-none w-full"
style={{ minHeight: '1em' }}
onBlur={(e) => {
const newText = e.currentTarget.textContent ?? ''
if (newText !== cell.text && onTextChange) {
onTextChange(cell, newText)
}
}}
>
{cell.text}
</span>
) : (
<span className="truncate">{cell.text}</span>
)}
</div>
)
})}
</div>
)
}
export function GridOverlay({
grid,
imageUrl,
onCellClick,
onCellTextChange,
selectedCell,
showEmpty = false,
showLabels = true,
showNumbers = false,
showTextLabels = false,
showMmGrid = false,
showTextAtPosition = false,
editableText = false,
highlightedBlockNumber,
className,
}: GridOverlayProps) {
@@ -125,6 +274,9 @@ export function GridOverlay({
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{/* 1mm Grid */}
{showMmGrid && <MmGridLines />}
{/* Column type labels */}
{showLabels && grid.column_types.length > 0 && (
<g>
@@ -150,15 +302,14 @@ export function GridOverlay({
</g>
)}
{/* Grid cells */}
{flatCells.map((cell) => {
{/* Grid cells (skip if showing text at position to avoid double rendering) */}
{!showTextAtPosition && flatCells.map((cell) => {
const colors = STATUS_COLORS[cell.status]
const isSelected = selectedCell?.row === cell.row && selectedCell?.col === cell.col
const isClickable = cell.status !== 'empty' && onCellClick
const blockNumber = getCellBlockNumber(cell, grid)
const isHighlighted = highlightedBlockNumber === blockNumber
// Skip empty cells if not showing them
if (!showEmpty && cell.status === 'empty') {
return null
}
@@ -170,7 +321,6 @@ export function GridOverlay({
onClick={() => handleCellClick(cell)}
className={isClickable ? 'cursor-pointer' : ''}
>
{/* Cell rectangle */}
<rect
x={cell.x}
y={cell.y}
@@ -186,7 +336,6 @@ export function GridOverlay({
)}
/>
{/* Block number badge */}
{showNumbers && cell.status !== 'empty' && (
<>
<rect
@@ -211,8 +360,7 @@ export function GridOverlay({
</>
)}
{/* Status indicator dot (only when not showing numbers) */}
{!showNumbers && cell.status !== 'empty' && (
{!showNumbers && !showTextLabels && cell.status !== 'empty' && (
<circle
cx={cell.x + 0.8}
cy={cell.y + 0.8}
@@ -223,7 +371,20 @@ export function GridOverlay({
/>
)}
{/* Confidence indicator (for recognized cells) */}
{showTextLabels && (cell.status === 'recognized' || cell.status === 'manual') && cell.text && (
<text
x={cell.x + cell.width / 2}
y={cell.y + cell.height / 2 + Math.min(cell.height * 0.2, 0.5)}
textAnchor="middle"
fontSize={Math.min(cell.height * 0.5, 1.4)}
fill={cell.status === 'manual' ? '#1e40af' : '#166534'}
fontWeight="500"
style={{ pointerEvents: 'none' }}
>
{cell.text.length > 15 ? cell.text.slice(0, 15) + '\u2026' : cell.text}
</text>
)}
{cell.status === 'recognized' && cell.confidence < 0.7 && (
<text
x={cell.x + cell.width - 0.5}
@@ -236,7 +397,6 @@ export function GridOverlay({
</text>
)}
{/* Selection highlight */}
{isSelected && (
<rect
x={cell.x}
@@ -254,7 +414,26 @@ export function GridOverlay({
)
})}
{/* Row boundaries (optional grid lines) */}
{/* Show cell outlines when in positioned text mode */}
{showTextAtPosition && flatCells.map((cell) => {
if (cell.status === 'empty') return null
return (
<rect
key={`outline-${cell.row}-${cell.col}`}
x={cell.x}
y={cell.y}
width={cell.width}
height={cell.height}
fill="none"
stroke="rgba(99, 102, 241, 0.2)"
strokeWidth={0.08}
rx={0.1}
style={{ pointerEvents: 'none' }}
/>
)
})}
{/* Row boundaries */}
{grid.row_boundaries.map((y, idx) => (
<line
key={`row-line-${idx}`}
@@ -282,22 +461,30 @@ export function GridOverlay({
/>
))}
</svg>
{/* Positioned text HTML overlay (outside SVG for proper text rendering) */}
{showTextAtPosition && (
<PositionedTextLayer
cells={flatCells.filter(c => c.status !== 'empty' && c.text)}
editable={editableText}
onTextChange={onCellTextChange}
/>
)}
</div>
)
}
/**
* GridStats Component
*
* Displays statistics about the grid detection results.
*/
interface GridStatsProps {
stats: GridData['stats']
deskewAngle?: number
source?: string
className?: string
}
export function GridStats({ stats, deskewAngle, className }: GridStatsProps) {
export function GridStats({ stats, deskewAngle, source, className }: GridStatsProps) {
const coveragePercent = Math.round(stats.coverage * 100)
return (
@@ -326,6 +513,11 @@ export function GridStats({ stats, deskewAngle, className }: GridStatsProps) {
Begradigt: {deskewAngle.toFixed(1)}
</div>
)}
{source && (
<div className="px-3 py-1.5 bg-cyan-50 text-cyan-700 rounded-lg text-sm font-medium">
Quelle: {source === 'tesseract+grid_service' ? 'Tesseract' : 'Vision LLM'}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,442 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
// =============================================================================
// TYPES
// =============================================================================
interface Message {
id: string
role: 'user' | 'agent'
content: string
timestamp: Date
}
interface ComplianceAdvisorWidgetProps {
currentStep?: string
}
// =============================================================================
// EXAMPLE QUESTIONS BY STEP
// =============================================================================
const EXAMPLE_QUESTIONS: Record<string, string[]> = {
vvt: [
'Was ist ein Verarbeitungsverzeichnis?',
'Welche Informationen muss ich erfassen?',
'Wie dokumentiere ich die Rechtsgrundlage?',
],
'compliance-scope': [
'Was bedeutet L3?',
'Wann brauche ich eine DSFA?',
'Was ist der Unterschied zwischen L2 und L3?',
],
tom: [
'Was sind TOM?',
'Welche Massnahmen sind erforderlich?',
'Wie dokumentiere ich Verschluesselung?',
],
dsfa: [
'Was ist eine DSFA?',
'Wann ist eine DSFA verpflichtend?',
'Wie bewerte ich Risiken?',
],
loeschfristen: [
'Wie definiere ich Loeschfristen?',
'Was ist der Unterschied zwischen Loeschpflicht und Aufbewahrungspflicht?',
'Wann muss ich Daten loeschen?',
],
default: [
'Wie starte ich mit dem SDK?',
'Was ist der erste Schritt?',
'Welche Compliance-Anforderungen gelten fuer KI-Systeme?',
],
}
// =============================================================================
// COMPONENT
// =============================================================================
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
const [isOpen, setIsOpen] = useState(false)
const [messages, setMessages] = useState<Message[]>([])
const [inputValue, setInputValue] = useState('')
const [isTyping, setIsTyping] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null)
const abortControllerRef = useRef<AbortController | null>(null)
// Get example questions for current step
const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
// Auto-scroll to bottom when messages change
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// Cleanup abort controller on unmount
useEffect(() => {
return () => {
abortControllerRef.current?.abort()
}
}, [])
// Handle send message with real LLM + RAG
const handleSendMessage = useCallback(
async (content: string) => {
if (!content.trim() || isTyping) return
const userMessage: Message = {
id: `msg-${Date.now()}`,
role: 'user',
content: content.trim(),
timestamp: new Date(),
}
setMessages((prev) => [...prev, userMessage])
setInputValue('')
setIsTyping(true)
const agentMessageId = `msg-${Date.now()}-agent`
// Create abort controller for this request
abortControllerRef.current = new AbortController()
try {
// Build conversation history for context
const history = messages.map((m) => ({
role: m.role === 'user' ? 'user' : 'assistant',
content: m.content,
}))
const response = await fetch('/api/sdk/compliance-advisor/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: content.trim(),
history,
currentStep,
}),
signal: abortControllerRef.current.signal,
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unbekannter Fehler' }))
throw new Error(errorData.error || `Server-Fehler (${response.status})`)
}
// Add empty agent message for streaming
setMessages((prev) => [
...prev,
{
id: agentMessageId,
role: 'agent',
content: '',
timestamp: new Date(),
},
])
// Read streaming response
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let accumulated = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
accumulated += decoder.decode(value, { stream: true })
// Update agent message with accumulated content
const currentText = accumulated
setMessages((prev) =>
prev.map((m) => (m.id === agentMessageId ? { ...m, content: currentText } : m))
)
}
setIsTyping(false)
} catch (error) {
if ((error as Error).name === 'AbortError') {
// User cancelled, keep partial response
setIsTyping(false)
return
}
const errorMessage =
error instanceof Error ? error.message : 'Verbindung fehlgeschlagen'
// Add or update agent message with error
setMessages((prev) => {
const hasAgent = prev.some((m) => m.id === agentMessageId)
if (hasAgent) {
return prev.map((m) =>
m.id === agentMessageId
? { ...m, content: `Fehler: ${errorMessage}` }
: m
)
}
return [
...prev,
{
id: agentMessageId,
role: 'agent' as const,
content: `Fehler: ${errorMessage}`,
timestamp: new Date(),
},
]
})
setIsTyping(false)
}
},
[isTyping, messages, currentStep]
)
// Handle stop generation
const handleStopGeneration = useCallback(() => {
abortControllerRef.current?.abort()
setIsTyping(false)
}, [])
// Handle example question click
const handleExampleClick = (question: string) => {
handleSendMessage(question)
}
// Handle key press
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSendMessage(inputValue)
}
}
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className="fixed bottom-6 right-6 w-14 h-14 bg-indigo-600 hover:bg-indigo-700 text-white rounded-full shadow-lg flex items-center justify-center transition-all duration-200 hover:scale-110 z-50"
aria-label="Compliance Advisor oeffnen"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
</button>
)
}
return (
<div className="fixed bottom-6 right-6 w-[400px] h-[500px] bg-white rounded-2xl shadow-2xl flex flex-col z-50 border border-gray-200">
{/* Header */}
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 text-white px-4 py-3 rounded-t-2xl flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
</div>
<div>
<div className="font-semibold text-sm">Compliance Advisor</div>
<div className="text-xs text-white/80">KI-gestuetzter Assistent</div>
</div>
</div>
<button
onClick={() => setIsOpen(false)}
className="text-white/80 hover:text-white transition-colors"
aria-label="Schliessen"
>
<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>
{/* Messages Area */}
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
{messages.length === 0 ? (
<div className="text-center py-8">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-8 h-8 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
</div>
<h3 className="text-sm font-medium text-gray-900 mb-2">
Willkommen beim Compliance Advisor
</h3>
<p className="text-xs text-gray-500 mb-4">
Stellen Sie Fragen zu DSGVO, KI-Verordnung und mehr.
</p>
{/* Example Questions */}
<div className="text-left space-y-2">
<p className="text-xs font-medium text-gray-700 mb-2">
Beispielfragen:
</p>
{exampleQuestions.map((question, idx) => (
<button
key={idx}
onClick={() => handleExampleClick(question)}
className="w-full text-left px-3 py-2 text-xs bg-white hover:bg-purple-50 border border-gray-200 rounded-lg transition-colors text-gray-700"
>
{question}
</button>
))}
</div>
</div>
) : (
<>
{messages.map((message) => (
<div
key={message.id}
className={`flex ${
message.role === 'user' ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`max-w-[80%] rounded-lg px-3 py-2 ${
message.role === 'user'
? 'bg-indigo-600 text-white'
: 'bg-white border border-gray-200 text-gray-800'
}`}
>
<p
className={`text-sm ${message.role === 'agent' ? 'whitespace-pre-wrap' : ''}`}
>
{message.content || (message.role === 'agent' && isTyping ? '' : message.content)}
</p>
<p
className={`text-xs mt-1 ${
message.role === 'user'
? 'text-indigo-200'
: 'text-gray-400'
}`}
>
{message.timestamp.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
))}
{isTyping && (
<div className="flex justify-start">
<div className="bg-white border border-gray-200 rounded-lg px-3 py-2">
<div className="flex space-x-1">
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" />
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: '0.1s' }}
/>
<div
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
style={{ animationDelay: '0.2s' }}
/>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input Area */}
<div className="border-t border-gray-200 p-3 bg-white rounded-b-2xl">
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Frage eingeben..."
disabled={isTyping}
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent disabled:opacity-50"
/>
{isTyping ? (
<button
onClick={handleStopGeneration}
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
title="Generierung stoppen"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 6h12v12H6z"
/>
</svg>
</button>
) : (
<button
onClick={() => handleSendMessage(inputValue)}
disabled={!inputValue.trim()}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed 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 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
</button>
)}
</div>
</div>
</div>
)
}

View File

@@ -489,6 +489,30 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
isActive={pathname === '/sdk/security-backlog'}
collapsed={collapsed}
/>
<AdditionalModuleItem
href="/sdk/compliance-hub"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
}
label="Compliance Hub"
isActive={pathname === '/sdk/compliance-hub'}
collapsed={collapsed}
/>
<AdditionalModuleItem
href="/sdk/dsms"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
}
label="DSMS"
isActive={pathname === '/sdk/dsms'}
collapsed={collapsed}
/>
</div>
</nav>

View File

@@ -3,7 +3,7 @@
import React, { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useSDK, getStepById, getNextStep, getPreviousStep, SDKStep } from '@/lib/sdk'
import { useSDK, getStepById, getNextStep, getPreviousStep, SDKStep, SDK_STEPS } from '@/lib/sdk'
// =============================================================================
// TYPES
@@ -133,7 +133,7 @@ export function StepHeader({
// Calculate step progress within phase
const phaseSteps = currentStep ?
(currentStep.phase === 1 ? 8 : 11) : 0
SDK_STEPS.filter(s => s.phase === currentStep.phase).length : 0
const stepNumber = currentStep?.order || 0
return (
@@ -314,6 +314,28 @@ export const STEP_EXPLANATIONS = {
},
],
},
'compliance-scope': {
title: 'Compliance Scope',
description: 'Umfang und Tiefe Ihrer Compliance-Dokumentation bestimmen',
explanation: 'Die Compliance Scope Engine bestimmt deterministisch, welche Dokumente Sie in welcher Tiefe benoetigen. Basierend auf 35 Fragen in 6 Bloecken werden Risiko-, Komplexitaets- und Assurance-Scores berechnet, die in ein 4-Level-Modell (L1 Lean bis L4 Zertifizierungsbereit) muenden.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Deterministisch',
description: 'Alle Entscheidungen sind nachvollziehbar — keine KI, keine Black Box. Jede Einstufung wird mit Rechtsgrundlage und Audit-Trail begruendet.',
},
{
icon: 'info' as const,
title: '4-Level-Modell',
description: 'L1 (Lean Startup) bis L4 (Zertifizierungsbereit). Hard Triggers (Art. 9, Minderjaehrige, Zertifizierungsziele) heben das Level automatisch an.',
},
{
icon: 'warning' as const,
title: 'Hard Triggers',
description: '50 deterministische Regeln pruefen besondere Kategorien (Art. 9), Minderjaehrige, KI-Einsatz, Drittlandtransfers und Zertifizierungsziele.',
},
],
},
'use-case-assessment': {
title: 'Anwendungsfall-Erfassung',
description: 'Erfassen Sie Ihre KI-Anwendungsfälle systematisch',
@@ -487,34 +509,44 @@ export const STEP_EXPLANATIONS = {
'tom': {
title: 'Technische und Organisatorische Massnahmen',
description: 'Dokumentieren Sie Ihre TOMs nach Art. 32 DSGVO',
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Sie umfassen Zutrittskontrolle, Zugangskontrolle, Zugriffskontrolle und mehr.',
explanation: 'TOMs sind konkrete Sicherheitsmassnahmen zum Schutz personenbezogener Daten. Das Dashboard zeigt den Status aller aus dem TOM Generator abgeleiteten Massnahmen mit SDM-Mapping und Gap-Analyse.',
tips: [
{
icon: 'info' as const,
title: 'Kategorien',
description: 'TOMs werden in technische (z.B. Verschluesselung) und organisatorische (z.B. Schulungen) Massnahmen unterteilt.',
icon: 'warning' as const,
title: 'Nachweispflicht',
description: 'TOMs muessen nachweisbar real sein. Verknuepfen Sie Evidence-Dokumente (Policies, Zertifikate, Screenshots) mit jeder Massnahme, um die Rechenschaftspflicht (Art. 5 Abs. 2 DSGVO) zu erfuellen.',
},
{
icon: 'success' as const,
title: 'Nachweis',
description: 'Dokumentieren Sie fuer jede TOM einen Nachweis der Umsetzung.',
icon: 'info' as const,
title: 'Generator nutzen',
description: 'Der 6-Schritt-Wizard leitet TOMs systematisch aus Ihrem Risikoprofil ab. Starten Sie dort, um eine vollstaendige Baseline zu erhalten.',
},
{
icon: 'info' as const,
title: 'SDM-Mapping',
description: 'Kontrollen werden den 7 SDM-Gewaehrleistungszielen zugeordnet: Verfuegbarkeit, Integritaet, Vertraulichkeit, Nichtverkettung, Intervenierbarkeit, Transparenz, Datenminimierung.',
},
],
},
'vvt': {
title: 'Verarbeitungsverzeichnis',
description: 'Erstellen Sie Ihr Verzeichnis nach Art. 30 DSGVO',
explanation: 'Das Verarbeitungsverzeichnis dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Es ist fuer die meisten Unternehmen Pflicht.',
description: 'Erstellen und verwalten Sie Ihr Verzeichnis nach Art. 30 DSGVO',
explanation: 'Das Verarbeitungsverzeichnis (VVT) dokumentiert alle Verarbeitungstaetigkeiten mit personenbezogenen Daten. Der integrierte Generator-Fragebogen befuellt 70-90% der Pflichtfelder automatisch anhand Ihres Unternehmensprofils.',
tips: [
{
icon: 'warning' as const,
title: 'Vollstaendigkeit',
description: 'Das VVT muss alle Verarbeitungen enthalten. Fehlende Eintraege koennen bei Audits zu Beanstandungen fuehren.',
title: 'Pflicht fuer alle',
description: 'Die Ausnahme fuer Unternehmen <250 Mitarbeiter greift nur bei gelegentlicher, risikoarmer Verarbeitung ohne besondere Kategorien (Art. 30 Abs. 5).',
},
{
icon: 'info' as const,
title: 'Pflichtangaben',
description: 'Jeder Eintrag muss enthalten: Zweck, Datenkategorien, Empfaenger, Loeschfristen, TOMs.',
title: 'Zweck-zuerst',
description: 'Definieren Sie Verarbeitungen nach Geschaeftszweck, nicht nach Tool. Ein Tool kann mehrere Verarbeitungen abdecken, eine Verarbeitung kann mehrere Tools nutzen.',
},
{
icon: 'info' as const,
title: 'Kein oeffentliches Dokument',
description: 'Das VVT ist ein internes Dokument. Es muss der Aufsichtsbehoerde nur auf Verlangen vorgelegt werden (Art. 30 Abs. 4).',
},
],
},
@@ -555,17 +587,22 @@ export const STEP_EXPLANATIONS = {
'loeschfristen': {
title: 'Loeschfristen',
description: 'Definieren Sie Aufbewahrungsrichtlinien fuer Ihre Daten',
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Nach Ablauf muessen die Daten geloescht oder anonymisiert werden.',
explanation: 'Loeschfristen legen fest, wie lange personenbezogene Daten gespeichert werden duerfen. Die 3-Stufen-Logik (Zweckende, Aufbewahrungspflicht, Legal Hold) stellt sicher, dass alle gesetzlichen Anforderungen beruecksichtigt werden.',
tips: [
{
icon: 'warning' as const,
title: 'Gesetzliche Fristen',
description: 'Beachten Sie gesetzliche Aufbewahrungspflichten (z.B. Steuerrecht: 10 Jahre, Handelsrecht: 6 Jahre).',
title: '3-Stufen-Logik',
description: 'Jede Loeschfrist folgt einer 3-Stufen-Logik: 1. Zweckende (Daten werden nach Zweckwegfall geloescht), 2. Aufbewahrungspflicht (gesetzliche Fristen verhindern Loeschung), 3. Legal Hold (laufende Verfahren blockieren Loeschung).',
},
{
icon: 'lightbulb' as const,
title: 'Automatisierung',
description: 'Richten Sie automatische Loeschprozesse ein, um Compliance sicherzustellen.',
icon: 'info' as const,
title: 'Deutsche Rechtsgrundlagen',
description: 'Der Generator kennt die wichtigsten Aufbewahrungstreiber: AO (10 J. Steuer), HGB (10/6 J. Handel), UStG (10 J. Rechnungen), BGB (3 J. Verjaehrung), ArbZG (2 J. Zeiterfassung), AGG (6 Mon. Bewerbungen).',
},
{
icon: 'info' as const,
title: 'Backup-Behandlung',
description: 'Auch Backups muessen ins Loeschkonzept einbezogen werden. Daten koennen nach primaerer Loeschung noch in Backup-Systemen existieren.',
},
],
},
@@ -659,6 +696,91 @@ export const STEP_EXPLANATIONS = {
},
],
},
'source-policy': {
title: 'Source Policy',
description: 'Verwalten Sie Ihre Datenquellen-Governance',
explanation: 'Die Source Policy definiert, welche externen Datenquellen fuer Ihre Anwendung zugelassen sind. Sie umfasst eine Whitelist, Operationsmatrix (Lookup, RAG, Training, Export), PII-Regeln und ein Audit-Trail.',
tips: [
{
icon: 'warning' as const,
title: 'Lizenzierung',
description: 'Pruefen Sie die Lizenzen aller Datenquellen (DL-DE-BY, CC-BY, CC0). Nicht-lizenzierte Quellen koennen rechtliche Risiken bergen.',
},
{
icon: 'info' as const,
title: 'PII-Regeln',
description: 'Definieren Sie klare Regeln fuer den Umgang mit personenbezogenen Daten in externen Quellen.',
},
],
},
'audit-report': {
title: 'Audit Report',
description: 'Erstellen und verwalten Sie Audit-Sitzungen',
explanation: 'Im Audit Report erstellen Sie formelle Audit-Sitzungen mit Pruefer-Informationen, fuehren die Pruefung durch und generieren PDF-Reports. Jede Sitzung dokumentiert den Compliance-Stand zu einem bestimmten Zeitpunkt.',
tips: [
{
icon: 'lightbulb' as const,
title: 'Regelmaessigkeit',
description: 'Fuehren Sie mindestens jaehrlich ein formelles Audit durch. Dokumentieren Sie Abweichungen und Massnahmenplaene.',
},
{
icon: 'success' as const,
title: 'PDF-Export',
description: 'Generieren Sie PDF-Reports in Deutsch oder Englisch fuer externe Pruefer.',
},
],
},
'workflow': {
title: 'Document Workflow',
description: 'Verwalten Sie den Freigabe-Prozess Ihrer rechtlichen Dokumente',
explanation: 'Der Document Workflow bietet einen Split-View-Editor mit synchronisiertem Scrollen. Dokumente durchlaufen den Status Draft → Review → Approved → Published. Aenderungen werden versioniert und der Freigabeprozess wird protokolliert.',
tips: [
{
icon: 'warning' as const,
title: 'Vier-Augen-Prinzip',
description: 'Rechtliche Dokumente sollten immer von mindestens einer weiteren Person geprueft werden, bevor sie veroeffentlicht werden.',
},
{
icon: 'info' as const,
title: 'Versionierung',
description: 'Jede Aenderung wird als neue Version gespeichert. So koennen Sie jederzeit den Stand eines Dokuments nachvollziehen.',
},
],
},
'consent-management': {
title: 'Consent Verwaltung',
description: 'Verwalten Sie Consent-Dokumente, Versionen und DSGVO-Prozesse',
explanation: 'Die Consent Verwaltung umfasst das Lifecycle-Management Ihrer rechtlichen Dokumente (AGB, Datenschutz, Cookie-Richtlinien), die Verwaltung von E-Mail-Templates (16 Lifecycle-E-Mails) und die Steuerung der DSGVO-Prozesse (Art. 15-21).',
tips: [
{
icon: 'info' as const,
title: 'Dokumentversionen',
description: 'Jede Aenderung an einem Consent-Dokument erzeugt eine neue Version. Aktive Nutzer muessen bei Aenderungen erneut zustimmen.',
},
{
icon: 'warning' as const,
title: 'DSGVO-Fristen',
description: 'Betroffenenrechte (Art. 15-21) haben gesetzliche Fristen. Auskunft: 30 Tage, Loeschung: unverzueglich.',
},
],
},
'notfallplan': {
title: 'Notfallplan & Breach Response',
description: 'Verwalten Sie Ihr Datenpannen-Management nach Art. 33/34 DSGVO',
explanation: 'Der Notfallplan definiert Ihren Prozess bei Datenpannen gemaess Art. 33/34 DSGVO. Er umfasst die 72-Stunden-Meldepflicht an die Aufsichtsbehoerde, die Benachrichtigung betroffener Personen bei hohem Risiko, Incident-Klassifizierung, Eskalationswege und Dokumentationspflichten.',
tips: [
{
icon: 'warning' as const,
title: '72-Stunden-Frist',
description: 'Art. 33 DSGVO: Meldung an die Aufsichtsbehoerde innerhalb von 72 Stunden nach Bekanntwerden. Verspaetete Meldungen muessen begruendet werden.',
},
{
icon: 'info' as const,
title: 'Dokumentationspflicht',
description: 'Art. 33 Abs. 5: Alle Datenpannen muessen dokumentiert werden — auch solche, die nicht meldepflichtig sind. Die Dokumentation muss der Aufsichtsbehoerde auf Verlangen vorgelegt werden koennen.',
},
],
},
}
export default StepHeader

View File

@@ -0,0 +1,362 @@
'use client'
import React, { useState } from 'react'
import type { ScopeDecision, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_DESCRIPTIONS, DEPTH_LEVEL_COLORS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
interface ScopeDecisionTabProps {
decision: ScopeDecision | null
}
export function ScopeDecisionTab({ decision }: ScopeDecisionTabProps) {
const [expandedTrigger, setExpandedTrigger] = useState<number | null>(null)
const [showAuditTrail, setShowAuditTrail] = useState(false)
if (!decision) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 rounded-full 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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Keine Entscheidung vorhanden</h3>
<p className="text-gray-600">Bitte führen Sie zuerst das Scope-Profiling durch.</p>
</div>
)
}
const getScoreColor = (score: number): string => {
if (score >= 80) return 'from-red-500 to-red-600'
if (score >= 60) return 'from-orange-500 to-orange-600'
if (score >= 40) return 'from-yellow-500 to-yellow-600'
return 'from-green-500 to-green-600'
}
const getSeverityBadge = (severity: 'low' | 'medium' | 'high' | 'critical') => {
const colors = {
low: 'bg-gray-100 text-gray-800',
medium: 'bg-yellow-100 text-yellow-800',
high: 'bg-orange-100 text-orange-800',
critical: 'bg-red-100 text-red-800',
}
const labels = {
low: 'Niedrig',
medium: 'Mittel',
high: 'Hoch',
critical: 'Kritisch',
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colors[severity]}`}>
{labels[severity]}
</span>
)
}
const renderScoreBar = (label: string, score: number | undefined) => {
const value = score ?? 0
return (
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700">{label}</span>
<span className="text-sm font-bold text-gray-900">{value}/100</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className={`h-full bg-gradient-to-r ${getScoreColor(value)} transition-all duration-500`}
style={{ width: `${value}%` }}
/>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Level Determination */}
<div className={`${DEPTH_LEVEL_COLORS[decision.level].bg} border-2 ${DEPTH_LEVEL_COLORS[decision.level].border} rounded-xl p-6`}>
<div className="flex items-start gap-6">
<div className={`flex-shrink-0 w-20 h-20 ${DEPTH_LEVEL_COLORS[decision.level].badge} rounded-xl flex items-center justify-center`}>
<span className={`text-3xl font-bold ${DEPTH_LEVEL_COLORS[decision.level].text}`}>
{decision.level}
</span>
</div>
<div className="flex-1">
<h2 className={`text-2xl font-bold ${DEPTH_LEVEL_COLORS[decision.level].text} mb-2`}>
{DEPTH_LEVEL_LABELS[decision.level]}
</h2>
<p className="text-gray-700 mb-3">{DEPTH_LEVEL_DESCRIPTIONS[decision.level]}</p>
{decision.reasoning && (
<p className="text-sm text-gray-600 italic">{decision.reasoning}</p>
)}
</div>
</div>
</div>
{/* Score Breakdown */}
{decision.scores && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Score-Analyse</h3>
<div className="space-y-4">
{renderScoreBar('Risiko-Score', decision.scores.riskScore)}
{renderScoreBar('Komplexitäts-Score', decision.scores.complexityScore)}
{renderScoreBar('Assurance-Score', decision.scores.assuranceScore)}
<div className="pt-4 border-t border-gray-200">
{renderScoreBar('Gesamt-Score', decision.scores.compositeScore)}
</div>
</div>
</div>
)}
{/* Hard Triggers */}
{decision.hardTriggers && decision.hardTriggers.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Hard-Trigger</h3>
<div className="space-y-3">
{decision.hardTriggers.map((trigger, idx) => (
<div
key={idx}
className={`border rounded-lg overflow-hidden ${
trigger.matched ? 'border-red-300 bg-red-50' : 'border-gray-200 bg-gray-50'
}`}
>
<button
type="button"
onClick={() => setExpandedTrigger(expandedTrigger === idx ? null : idx)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-opacity-80 transition-colors"
>
<div className="flex items-center gap-3">
{trigger.matched && (
<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>
)}
<span className="font-medium text-gray-900">{trigger.label}</span>
</div>
<svg
className={`w-5 h-5 text-gray-500 transition-transform ${
expandedTrigger === idx ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{expandedTrigger === idx && (
<div className="px-4 pb-4 pt-2 border-t border-gray-200">
<p className="text-sm text-gray-700 mb-2">{trigger.description}</p>
{trigger.legalReference && (
<p className="text-xs text-gray-600 mb-2">
<span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference}
</p>
)}
{trigger.matchedValue && (
<p className="text-xs text-gray-700">
<span className="font-medium">Erfasster Wert:</span> {trigger.matchedValue}
</p>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Required Documents */}
{decision.requiredDocuments && decision.requiredDocuments.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Erforderliche Dokumente</h3>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Typ</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Tiefe</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aufwand</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Status</th>
<th className="text-left py-3 px-4 text-sm font-semibold text-gray-700">Aktion</th>
</tr>
</thead>
<tbody>
{decision.requiredDocuments.map((doc, idx) => (
<tr key={idx} className="border-b border-gray-100 last:border-b-0 hover:bg-gray-50">
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">
{DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType}
</span>
{doc.isMandatory && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">
Pflicht
</span>
)}
</div>
</td>
<td className="py-3 px-4 text-sm text-gray-700">{doc.depthDescription}</td>
<td className="py-3 px-4 text-sm text-gray-700">
{doc.effortEstimate ? `${doc.effortEstimate.days} Tage` : '-'}
</td>
<td className="py-3 px-4">
{doc.triggeredByHardTrigger && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">
Hard-Trigger
</span>
)}
</td>
<td className="py-3 px-4">
{doc.sdkStepUrl && (
<a
href={doc.sdkStepUrl}
className="text-sm text-purple-600 hover:text-purple-700 font-medium"
>
Zum SDK-Schritt
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Risk Flags */}
{decision.riskFlags && decision.riskFlags.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Risiko-Flags</h3>
<div className="space-y-4">
{decision.riskFlags.map((flag, idx) => (
<div key={idx} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-gray-900">{flag.title}</h4>
{getSeverityBadge(flag.severity)}
</div>
<p className="text-sm text-gray-700 mb-2">{flag.description}</p>
<p className="text-sm text-gray-600">
<span className="font-medium">Empfehlung:</span> {flag.recommendation}
</p>
</div>
))}
</div>
</div>
)}
{/* Gap Analysis */}
{decision.gapAnalysis && decision.gapAnalysis.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Gap-Analyse</h3>
<div className="space-y-4">
{decision.gapAnalysis.map((gap, idx) => (
<div key={idx} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-gray-900">{gap.title}</h4>
{getSeverityBadge(gap.severity)}
</div>
<p className="text-sm text-gray-700 mb-2">{gap.description}</p>
<p className="text-sm text-gray-600 mb-2">
<span className="font-medium">Empfehlung:</span> {gap.recommendation}
</p>
{gap.relatedDocuments && gap.relatedDocuments.length > 0 && (
<div className="mt-2">
<span className="text-xs text-gray-500">Betroffene Dokumente: </span>
{gap.relatedDocuments.map((doc, docIdx) => (
<span
key={docIdx}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 mr-1"
>
{DOCUMENT_TYPE_LABELS[doc] || doc}
</span>
))}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Next Actions */}
{decision.nextActions && decision.nextActions.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Nächste Schritte</h3>
<div className="space-y-4">
{decision.nextActions.map((action, idx) => (
<div key={idx} className="flex gap-4">
<div className="flex-shrink-0 w-8 h-8 bg-purple-100 rounded-full flex items-center justify-center">
<span className="text-sm font-bold text-purple-700">{action.priority}</span>
</div>
<div className="flex-1">
<h4 className="font-semibold text-gray-900 mb-1">{action.title}</h4>
<p className="text-sm text-gray-700 mb-2">{action.description}</p>
<div className="flex items-center gap-3">
{action.effortDays && (
<span className="text-xs text-gray-600">
<span className="font-medium">Aufwand:</span> {action.effortDays} Tage
</span>
)}
{action.relatedDocuments && action.relatedDocuments.length > 0 && (
<span className="text-xs text-gray-600">
<span className="font-medium">Dokumente:</span> {action.relatedDocuments.length}
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Audit Trail */}
{decision.auditTrail && decision.auditTrail.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<button
type="button"
onClick={() => setShowAuditTrail(!showAuditTrail)}
className="w-full flex items-center justify-between mb-4"
>
<h3 className="text-lg font-semibold text-gray-900">Audit-Trail</h3>
<svg
className={`w-5 h-5 text-gray-500 transition-transform ${showAuditTrail ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showAuditTrail && (
<div className="space-y-3">
{decision.auditTrail.map((entry, idx) => (
<div key={idx} className="border-l-2 border-purple-300 pl-4 py-2">
<h4 className="font-medium text-gray-900 mb-1">{entry.step}</h4>
<p className="text-sm text-gray-700 mb-2">{entry.description}</p>
{entry.details && entry.details.length > 0 && (
<ul className="text-xs text-gray-600 space-y-1">
{entry.details.map((detail, detailIdx) => (
<li key={detailIdx}> {detail}</li>
))}
</ul>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,334 @@
'use client'
import React, { useState, useCallback } from 'react'
import type { ScopeDecision, ScopeProfilingAnswer } from '@/lib/sdk/compliance-scope-types'
import { DEPTH_LEVEL_LABELS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
interface ScopeExportTabProps {
decision: ScopeDecision | null
answers: ScopeProfilingAnswer[]
}
export function ScopeExportTab({ decision, answers }: ScopeExportTabProps) {
const [copiedMarkdown, setCopiedMarkdown] = useState(false)
const handleDownloadJSON = useCallback(() => {
if (!decision) return
const dataStr = JSON.stringify(decision, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = `compliance-scope-decision-${new Date().toISOString().split('T')[0]}.json`
link.click()
URL.revokeObjectURL(url)
}, [decision])
const handleDownloadCSV = useCallback(() => {
if (!decision || !decision.requiredDocuments) return
const headers = ['Typ', 'Tiefe', 'Aufwand (Tage)', 'Pflicht', 'Hard-Trigger']
const rows = decision.requiredDocuments.map((doc) => [
DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType,
doc.depthDescription,
doc.effortEstimate?.days?.toString() || '0',
doc.isMandatory ? 'Ja' : 'Nein',
doc.triggeredByHardTrigger ? 'Ja' : 'Nein',
])
const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n')
const dataBlob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = `compliance-scope-documents-${new Date().toISOString().split('T')[0]}.csv`
link.click()
URL.revokeObjectURL(url)
}, [decision])
const generateMarkdownSummary = useCallback(() => {
if (!decision) return ''
let markdown = `# Compliance Scope Entscheidung\n\n`
markdown += `**Datum:** ${new Date().toLocaleDateString('de-DE')}\n\n`
markdown += `## Einstufung\n\n`
markdown += `**Level:** ${decision.level} - ${DEPTH_LEVEL_LABELS[decision.level]}\n\n`
if (decision.reasoning) {
markdown += `**Begründung:** ${decision.reasoning}\n\n`
}
if (decision.scores) {
markdown += `## Scores\n\n`
markdown += `- **Risiko-Score:** ${decision.scores.riskScore}/100\n`
markdown += `- **Komplexitäts-Score:** ${decision.scores.complexityScore}/100\n`
markdown += `- **Assurance-Score:** ${decision.scores.assuranceScore}/100\n`
markdown += `- **Gesamt-Score:** ${decision.scores.compositeScore}/100\n\n`
}
if (decision.hardTriggers && decision.hardTriggers.length > 0) {
const matchedTriggers = decision.hardTriggers.filter((ht) => ht.matched)
if (matchedTriggers.length > 0) {
markdown += `## Aktive Hard-Trigger\n\n`
matchedTriggers.forEach((trigger) => {
markdown += `- **${trigger.label}**\n`
markdown += ` - ${trigger.description}\n`
if (trigger.legalReference) {
markdown += ` - Rechtsgrundlage: ${trigger.legalReference}\n`
}
})
markdown += `\n`
}
}
if (decision.requiredDocuments && decision.requiredDocuments.length > 0) {
markdown += `## Erforderliche Dokumente\n\n`
markdown += `| Typ | Tiefe | Aufwand | Pflicht | Hard-Trigger |\n`
markdown += `|-----|-------|---------|---------|-------------|\n`
decision.requiredDocuments.forEach((doc) => {
markdown += `| ${DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} | ${doc.depthDescription} | ${
doc.effortEstimate?.days || 0
} Tage | ${doc.isMandatory ? 'Ja' : 'Nein'} | ${doc.triggeredByHardTrigger ? 'Ja' : 'Nein'} |\n`
})
markdown += `\n`
}
if (decision.riskFlags && decision.riskFlags.length > 0) {
markdown += `## Risiko-Flags\n\n`
decision.riskFlags.forEach((flag) => {
markdown += `### ${flag.title} (${flag.severity})\n\n`
markdown += `${flag.description}\n\n`
markdown += `**Empfehlung:** ${flag.recommendation}\n\n`
})
}
if (decision.nextActions && decision.nextActions.length > 0) {
markdown += `## Nächste Schritte\n\n`
decision.nextActions.forEach((action) => {
markdown += `${action.priority}. **${action.title}**\n`
markdown += ` ${action.description}\n`
if (action.effortDays) {
markdown += ` Aufwand: ${action.effortDays} Tage\n`
}
markdown += `\n`
})
}
return markdown
}, [decision])
const handleCopyMarkdown = useCallback(() => {
const markdown = generateMarkdownSummary()
navigator.clipboard.writeText(markdown).then(() => {
setCopiedMarkdown(true)
setTimeout(() => setCopiedMarkdown(false), 2000)
})
}, [generateMarkdownSummary])
const handlePrintView = useCallback(() => {
if (!decision) return
const markdown = generateMarkdownSummary()
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Compliance Scope Entscheidung</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
max-width: 900px;
margin: 40px auto;
padding: 20px;
line-height: 1.6;
}
h1 { color: #7c3aed; border-bottom: 3px solid #7c3aed; padding-bottom: 10px; }
h2 { color: #5b21b6; margin-top: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 8px; }
h3 { color: #4c1d95; margin-top: 20px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #d1d5db; padding: 12px; text-align: left; }
th { background-color: #f3f4f6; font-weight: 600; }
ul { list-style-type: disc; padding-left: 20px; }
li { margin: 8px 0; }
@media print {
body { margin: 20px; }
h1, h2, h3 { page-break-after: avoid; }
table { page-break-inside: avoid; }
}
</style>
</head>
<body>
<pre style="white-space: pre-wrap; font-family: inherit;">${markdown}</pre>
</body>
</html>
`
const printWindow = window.open('', '_blank')
if (printWindow) {
printWindow.document.write(htmlContent)
printWindow.document.close()
printWindow.focus()
setTimeout(() => printWindow.print(), 250)
}
}, [decision, generateMarkdownSummary])
if (!decision) {
return (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 rounded-full 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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Keine Daten zum Export</h3>
<p className="text-gray-600">Bitte führen Sie zuerst das Scope-Profiling durch.</p>
</div>
)
}
return (
<div className="space-y-6">
{/* JSON Export */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
<h3 className="text-lg font-semibold text-gray-900">JSON Export</h3>
</div>
<p className="text-sm text-gray-600 mb-3">
Exportieren Sie die vollständige Entscheidung als strukturierte JSON-Datei für weitere Verarbeitung oder
Archivierung.
</p>
<button
onClick={handleDownloadJSON}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm"
>
JSON herunterladen
</button>
</div>
</div>
</div>
{/* CSV Export */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 className="text-lg font-semibold text-gray-900">CSV Export</h3>
</div>
<p className="text-sm text-gray-600 mb-3">
Exportieren Sie die Liste der erforderlichen Dokumente als CSV-Datei für Excel, Google Sheets oder andere
Tabellenkalkulationen.
</p>
<button
onClick={handleDownloadCSV}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium text-sm"
>
CSV herunterladen
</button>
</div>
</div>
</div>
{/* Markdown Summary */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-2 mb-3">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<h3 className="text-lg font-semibold text-gray-900">Markdown-Zusammenfassung</h3>
</div>
<p className="text-sm text-gray-600 mb-3">
Strukturierte Zusammenfassung im Markdown-Format für Dokumentation oder Berichte.
</p>
<textarea
readOnly
value={generateMarkdownSummary()}
className="w-full h-64 px-4 py-3 border border-gray-300 rounded-lg font-mono text-sm text-gray-700 resize-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
/>
<button
onClick={handleCopyMarkdown}
className="mt-3 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium text-sm"
>
{copiedMarkdown ? 'Kopiert!' : 'Kopieren'}
</button>
</div>
{/* Print View */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<svg className="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z"
/>
</svg>
<h3 className="text-lg font-semibold text-gray-900">Druckansicht</h3>
</div>
<p className="text-sm text-gray-600 mb-3">
Öffnen Sie eine druckfreundliche HTML-Ansicht der Entscheidung in einem neuen Fenster.
</p>
<button
onClick={handlePrintView}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors font-medium text-sm"
>
Druckansicht öffnen
</button>
</div>
</div>
</div>
{/* Export Info */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex gap-3">
<svg className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" 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="text-sm font-semibold text-blue-900 mb-1">Export-Hinweise</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li> JSON-Exporte enthalten alle Daten und können wieder importiert werden</li>
<li> CSV-Exporte sind ideal für Tabellenkalkulation und Aufwandsschätzungen</li>
<li> Markdown eignet sich für Dokumentation und Berichte</li>
<li> Die Druckansicht ist optimiert für PDF-Export über den Browser</li>
</ul>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,267 @@
'use client'
import React from 'react'
import type { ComplianceScopeState, ScopeDecision, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_DESCRIPTIONS, DEPTH_LEVEL_COLORS, DOCUMENT_TYPE_LABELS } from '@/lib/sdk/compliance-scope-types'
interface ScopeOverviewTabProps {
scopeState: ComplianceScopeState
onStartProfiling: () => void
onRefreshDecision: () => void
}
export function ScopeOverviewTab({ scopeState, onStartProfiling, onRefreshDecision }: ScopeOverviewTabProps) {
const { decision, answers } = scopeState
const hasAnswers = answers && answers.length > 0
const getScoreColor = (score: number): string => {
if (score >= 80) return 'from-red-500 to-red-600'
if (score >= 60) return 'from-orange-500 to-orange-600'
if (score >= 40) return 'from-yellow-500 to-yellow-600'
return 'from-green-500 to-green-600'
}
const getScoreColorBg = (score: number): string => {
if (score >= 80) return 'bg-red-100'
if (score >= 60) return 'bg-orange-100'
if (score >= 40) return 'bg-yellow-100'
return 'bg-green-100'
}
const renderScoreGauge = (label: string, score: number | undefined) => {
const value = score ?? 0
return (
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-gray-700">{label}</span>
<span className="text-sm font-bold text-gray-900">{value}/100</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className={`h-full bg-gradient-to-r ${getScoreColor(value)} transition-all duration-500`}
style={{ width: `${value}%` }}
/>
</div>
</div>
)
}
const renderLevelBadge = () => {
if (!decision?.level) {
return (
<div className="bg-gray-100 border border-gray-300 rounded-xl p-8 text-center">
<div className="inline-flex items-center justify-center w-24 h-24 bg-gray-200 rounded-full mb-4">
<span className="text-4xl font-bold text-gray-400">?</span>
</div>
<h3 className="text-xl font-semibold text-gray-600 mb-2">Noch nicht bewertet</h3>
<p className="text-gray-500">
Führen Sie das Scope-Profiling durch, um Ihre Compliance-Tiefe zu bestimmen.
</p>
</div>
)
}
const levelColors = DEPTH_LEVEL_COLORS[decision.level]
return (
<div className={`${levelColors.bg} border-2 ${levelColors.border} rounded-xl p-8 text-center`}>
<div className={`inline-flex items-center justify-center w-24 h-24 ${levelColors.badge} rounded-full mb-4`}>
<span className={`text-4xl font-bold ${levelColors.text}`}>{decision.level}</span>
</div>
<h3 className={`text-xl font-semibold ${levelColors.text} mb-2`}>
{DEPTH_LEVEL_LABELS[decision.level]}
</h3>
<p className="text-gray-600">{DEPTH_LEVEL_DESCRIPTIONS[decision.level]}</p>
</div>
)
}
const renderActiveHardTriggers = () => {
if (!decision?.hardTriggers || decision.hardTriggers.length === 0) {
return null
}
const activeHardTriggers = decision.hardTriggers.filter((ht) => ht.matched)
if (activeHardTriggers.length === 0) {
return null
}
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-2 mb-4">
<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>
<h3 className="text-lg font-semibold text-gray-900">Aktive Hard-Trigger</h3>
</div>
<div className="space-y-3">
{activeHardTriggers.map((trigger, idx) => (
<div
key={idx}
className="border-l-4 border-red-500 bg-red-50 rounded-r-lg p-4"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-semibold text-gray-900">{trigger.label}</h4>
<p className="text-sm text-gray-600 mt-1">{trigger.description}</p>
{trigger.legalReference && (
<p className="text-xs text-gray-500 mt-2">
<span className="font-medium">Rechtsgrundlage:</span> {trigger.legalReference}
</p>
)}
{trigger.matchedValue && (
<p className="text-xs text-gray-700 mt-1">
<span className="font-medium">Erfasster Wert:</span> {trigger.matchedValue}
</p>
)}
</div>
</div>
</div>
))}
</div>
</div>
)
}
const renderDocumentSummary = () => {
if (!decision?.requiredDocuments) {
return null
}
const mandatoryDocs = decision.requiredDocuments.filter((doc) => doc.isMandatory)
const optionalDocs = decision.requiredDocuments.filter((doc) => !doc.isMandatory)
const totalEffortDays = decision.requiredDocuments.reduce(
(sum, doc) => sum + (doc.effortEstimate?.days ?? 0),
0
)
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Dokumenten-Übersicht</h3>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-3xl font-bold text-purple-600">{mandatoryDocs.length}</div>
<div className="text-sm text-gray-600 mt-1">Pflichtdokumente</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">{optionalDocs.length}</div>
<div className="text-sm text-gray-600 mt-1">Optional</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-gray-900">{totalEffortDays}</div>
<div className="text-sm text-gray-600 mt-1">Tage Aufwand (geschätzt)</div>
</div>
</div>
</div>
)
}
const renderRiskFlagsSummary = () => {
if (!decision?.riskFlags || decision.riskFlags.length === 0) {
return null
}
const critical = decision.riskFlags.filter((rf) => rf.severity === 'critical').length
const high = decision.riskFlags.filter((rf) => rf.severity === 'high').length
const medium = decision.riskFlags.filter((rf) => rf.severity === 'medium').length
return (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center gap-2 mb-4">
<svg className="w-5 h-5 text-orange-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>
<h3 className="text-lg font-semibold text-gray-900">Risiko-Flags</h3>
</div>
<div className="flex gap-6">
{critical > 0 && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
Kritisch
</span>
<span className="text-lg font-bold text-red-600">{critical}</span>
</div>
)}
{high > 0 && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
Hoch
</span>
<span className="text-lg font-bold text-orange-600">{high}</span>
</div>
)}
{medium > 0 && (
<div className="flex items-center gap-2">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
Mittel
</span>
<span className="text-lg font-bold text-yellow-600">{medium}</span>
</div>
)}
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Level Badge */}
{renderLevelBadge()}
{/* Scores Section */}
{decision && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Score-Übersicht</h3>
<div className="space-y-4">
{renderScoreGauge('Risiko-Score', decision.scores?.riskScore)}
{renderScoreGauge('Komplexitäts-Score', decision.scores?.complexityScore)}
{renderScoreGauge('Assurance-Score', decision.scores?.assuranceScore)}
<div className="pt-4 border-t border-gray-200">
{renderScoreGauge('Gesamt-Score', decision.scores?.compositeScore)}
</div>
</div>
</div>
)}
{/* Active Hard Triggers */}
{renderActiveHardTriggers()}
{/* Document Summary */}
{renderDocumentSummary()}
{/* Risk Flags Summary */}
{renderRiskFlagsSummary()}
{/* CTA Section */}
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{!hasAnswers ? 'Bereit für das Scope-Profiling?' : 'Ergebnis aktualisieren'}
</h3>
<p className="text-gray-600">
{!hasAnswers
? 'Beantworten Sie einige Fragen zu Ihrem Unternehmen und erhalten Sie eine präzise Compliance-Bewertung.'
: 'Haben sich Ihre Unternehmensparameter geändert? Aktualisieren Sie Ihre Bewertung.'}
</p>
</div>
<button
onClick={!hasAnswers ? onStartProfiling : onRefreshDecision}
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium whitespace-nowrap"
>
{!hasAnswers ? 'Scope-Profiling starten' : 'Ergebnis aktualisieren'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,410 @@
'use client'
import React, { useState, useCallback } from 'react'
import type { ScopeProfilingAnswer, ComplianceDepthLevel } from '@/lib/sdk/compliance-scope-types'
import { SCOPE_QUESTION_BLOCKS, getBlockProgress, getTotalProgress, getAnswerValue, getAllQuestions } from '@/lib/sdk/compliance-scope-profiling'
import type { CompanyProfile } from '@/lib/sdk/types'
import { prefillFromCompanyProfile } from '@/lib/sdk/compliance-scope-profiling'
import { DEPTH_LEVEL_LABELS, DEPTH_LEVEL_COLORS } from '@/lib/sdk/compliance-scope-types'
interface ScopeWizardTabProps {
answers: ScopeProfilingAnswer[]
onAnswersChange: (answers: ScopeProfilingAnswer[]) => void
onComplete: () => void
companyProfile: CompanyProfile | null
currentLevel: ComplianceDepthLevel | null
}
export function ScopeWizardTab({
answers,
onAnswersChange,
onComplete,
companyProfile,
currentLevel,
}: ScopeWizardTabProps) {
const [currentBlockIndex, setCurrentBlockIndex] = useState(0)
const currentBlock = SCOPE_QUESTION_BLOCKS[currentBlockIndex]
const totalProgress = getTotalProgress(answers)
const handleAnswerChange = useCallback(
(questionId: string, value: any) => {
const existingIndex = answers.findIndex((a) => a.questionId === questionId)
if (existingIndex >= 0) {
const newAnswers = [...answers]
newAnswers[existingIndex] = { questionId, value, answeredAt: new Date().toISOString() }
onAnswersChange(newAnswers)
} else {
onAnswersChange([...answers, { questionId, value, answeredAt: new Date().toISOString() }])
}
},
[answers, onAnswersChange]
)
const handlePrefillFromProfile = useCallback(() => {
if (!companyProfile) return
const prefilledAnswers = prefillFromCompanyProfile(companyProfile, answers)
onAnswersChange(prefilledAnswers)
}, [companyProfile, answers, onAnswersChange])
const handleNext = useCallback(() => {
if (currentBlockIndex < SCOPE_QUESTION_BLOCKS.length - 1) {
setCurrentBlockIndex(currentBlockIndex + 1)
} else {
onComplete()
}
}, [currentBlockIndex, onComplete])
const handleBack = useCallback(() => {
if (currentBlockIndex > 0) {
setCurrentBlockIndex(currentBlockIndex - 1)
}
}, [currentBlockIndex])
const renderQuestion = (question: any) => {
const currentValue = getAnswerValue(answers, question.id)
switch (question.type) {
case 'boolean':
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-900">
{question.question}
{question.required && <span className="text-red-500 ml-1">*</span>}
</label>
{question.helpText && (
<button
type="button"
className="text-gray-400 hover:text-gray-600"
title={question.helpText}
>
<svg className="w-5 h-5" 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>
</button>
)}
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => handleAnswerChange(question.id, true)}
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
currentValue === true
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
Ja
</button>
<button
type="button"
onClick={() => handleAnswerChange(question.id, false)}
className={`flex-1 py-2 px-4 rounded-lg border-2 transition-all ${
currentValue === false
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
Nein
</button>
</div>
</div>
)
case 'single':
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-900">
{question.question}
{question.required && <span className="text-red-500 ml-1">*</span>}
{question.helpText && (
<button
type="button"
className="ml-2 text-gray-400 hover:text-gray-600 inline"
title={question.helpText}
>
<svg className="w-4 h-4 inline" 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>
</button>
)}
</label>
<div className="space-y-2">
{question.options?.map((option: any) => (
<button
key={option.value}
type="button"
onClick={() => handleAnswerChange(question.id, option.value)}
className={`w-full text-left py-3 px-4 rounded-lg border-2 transition-all ${
currentValue === option.value
? 'border-purple-500 bg-purple-50 text-purple-700 font-medium'
: 'border-gray-300 bg-white text-gray-700 hover:border-gray-400'
}`}
>
{option.label}
</button>
))}
</div>
</div>
)
case 'multi':
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-900">
{question.question}
{question.required && <span className="text-red-500 ml-1">*</span>}
{question.helpText && (
<button
type="button"
className="ml-2 text-gray-400 hover:text-gray-600 inline"
title={question.helpText}
>
<svg className="w-4 h-4 inline" 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>
</button>
)}
</label>
<div className="space-y-2">
{question.options?.map((option: any) => {
const selectedValues = Array.isArray(currentValue) ? currentValue : []
const isChecked = selectedValues.includes(option.value)
return (
<label
key={option.value}
className={`flex items-center gap-3 py-3 px-4 rounded-lg border-2 cursor-pointer transition-all ${
isChecked
? 'border-purple-500 bg-purple-50'
: 'border-gray-300 bg-white hover:border-gray-400'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => {
const newValues = e.target.checked
? [...selectedValues, option.value]
: selectedValues.filter((v) => v !== option.value)
handleAnswerChange(question.id, newValues)
}}
className="w-5 h-5 text-purple-600 border-gray-300 rounded focus:ring-purple-500"
/>
<span className={isChecked ? 'text-purple-700 font-medium' : 'text-gray-700'}>
{option.label}
</span>
</label>
)
})}
</div>
</div>
)
case 'number':
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-900">
{question.question}
{question.required && <span className="text-red-500 ml-1">*</span>}
{question.helpText && (
<button
type="button"
className="ml-2 text-gray-400 hover:text-gray-600 inline"
title={question.helpText}
>
<svg className="w-4 h-4 inline" 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>
</button>
)}
</label>
<input
type="number"
value={currentValue ?? ''}
onChange={(e) => handleAnswerChange(question.id, parseInt(e.target.value, 10))}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Zahl eingeben"
/>
</div>
)
case 'text':
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-900">
{question.question}
{question.required && <span className="text-red-500 ml-1">*</span>}
{question.helpText && (
<button
type="button"
className="ml-2 text-gray-400 hover:text-gray-600 inline"
title={question.helpText}
>
<svg className="w-4 h-4 inline" 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>
</button>
)}
</label>
<input
type="text"
value={currentValue ?? ''}
onChange={(e) => handleAnswerChange(question.id, e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Text eingeben"
/>
</div>
)
default:
return null
}
}
return (
<div className="flex gap-6 h-full">
{/* Left Sidebar - Block Navigation */}
<div className="w-64 flex-shrink-0">
<div className="bg-white rounded-xl border border-gray-200 p-4 sticky top-4">
<h3 className="text-sm font-semibold text-gray-900 mb-3">Fortschritt</h3>
<div className="space-y-2">
{SCOPE_QUESTION_BLOCKS.map((block, idx) => {
const progress = getBlockProgress(answers, block.id)
const isActive = idx === currentBlockIndex
return (
<button
key={block.id}
type="button"
onClick={() => setCurrentBlockIndex(idx)}
className={`w-full text-left px-3 py-2 rounded-lg transition-all ${
isActive
? 'bg-purple-50 border-2 border-purple-500'
: 'bg-gray-50 border border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-center justify-between mb-1">
<span className={`text-sm font-medium ${isActive ? 'text-purple-700' : 'text-gray-700'}`}>
{block.title}
</span>
<span className={`text-xs font-semibold ${isActive ? 'text-purple-600' : 'text-gray-500'}`}>
{progress}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5 overflow-hidden">
<div
className={`h-full transition-all ${isActive ? 'bg-purple-500' : 'bg-gray-400'}`}
style={{ width: `${progress}%` }}
/>
</div>
</button>
)
})}
</div>
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 space-y-6">
{/* Progress Bar */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700">Gesamtfortschritt</span>
<div className="flex items-center gap-3">
{currentLevel && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Vorläufige Einstufung:</span>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold ${DEPTH_LEVEL_COLORS[currentLevel].badge} ${DEPTH_LEVEL_COLORS[currentLevel].text}`}
>
{currentLevel} - {DEPTH_LEVEL_LABELS[currentLevel]}
</span>
</div>
)}
<span className="text-sm font-bold text-gray-900">{totalProgress}%</span>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-blue-500 transition-all duration-500"
style={{ width: `${totalProgress}%` }}
/>
</div>
</div>
{/* Current Block */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{currentBlock.title}</h2>
<p className="text-gray-600">{currentBlock.description}</p>
</div>
{companyProfile && (
<button
type="button"
onClick={handlePrefillFromProfile}
className="px-4 py-2 text-sm bg-blue-50 text-blue-700 border border-blue-300 rounded-lg hover:bg-blue-100 transition-colors whitespace-nowrap"
>
Aus Unternehmensprofil übernehmen
</button>
)}
</div>
{/* Questions */}
<div className="space-y-6">
{currentBlock.questions.map((question) => (
<div key={question.id} className="border-b border-gray-100 pb-6 last:border-b-0 last:pb-0">
{renderQuestion(question)}
</div>
))}
</div>
</div>
{/* Navigation Buttons */}
<div className="flex items-center justify-between bg-white rounded-xl border border-gray-200 p-4">
<button
type="button"
onClick={handleBack}
disabled={currentBlockIndex === 0}
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Zurück
</button>
<span className="text-sm text-gray-600">
Block {currentBlockIndex + 1} von {SCOPE_QUESTION_BLOCKS.length}
</span>
<button
type="button"
onClick={handleNext}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
>
{currentBlockIndex === SCOPE_QUESTION_BLOCKS.length - 1 ? 'Auswertung starten' : 'Weiter'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { ScopeOverviewTab } from './ScopeOverviewTab'
export { ScopeWizardTab } from './ScopeWizardTab'
export { ScopeDecisionTab } from './ScopeDecisionTab'
export { ScopeExportTab } from './ScopeExportTab'

View File

@@ -0,0 +1,320 @@
'use client'
import { useState } from 'react'
import { BookOpen, ExternalLink, Scale, ChevronDown, ChevronUp, Info } from 'lucide-react'
import {
DSFALicenseCode,
DSFA_LICENSE_LABELS,
SourceAttributionProps
} from '@/lib/sdk/types'
/**
* Get license badge color based on license type
*/
function getLicenseBadgeColor(licenseCode: DSFALicenseCode): string {
switch (licenseCode) {
case 'DL-DE-BY-2.0':
case 'DL-DE-ZERO-2.0':
return 'bg-blue-100 text-blue-700 border-blue-200'
case 'CC-BY-4.0':
return 'bg-green-100 text-green-700 border-green-200'
case 'EDPB-LICENSE':
return 'bg-purple-100 text-purple-700 border-purple-200'
case 'PUBLIC_DOMAIN':
return 'bg-gray-100 text-gray-700 border-gray-200'
case 'PROPRIETARY':
return 'bg-amber-100 text-amber-700 border-amber-200'
default:
return 'bg-slate-100 text-slate-700 border-slate-200'
}
}
/**
* Get license URL based on license code
*/
function getLicenseUrl(licenseCode: DSFALicenseCode): string | null {
switch (licenseCode) {
case 'DL-DE-BY-2.0':
return 'https://www.govdata.de/dl-de/by-2-0'
case 'DL-DE-ZERO-2.0':
return 'https://www.govdata.de/dl-de/zero-2-0'
case 'CC-BY-4.0':
return 'https://creativecommons.org/licenses/by/4.0/'
case 'EDPB-LICENSE':
return 'https://edpb.europa.eu/about-edpb/legal-notice_en'
default:
return null
}
}
/**
* License badge component
*/
function LicenseBadge({ licenseCode }: { licenseCode: DSFALicenseCode }) {
const licenseUrl = getLicenseUrl(licenseCode)
const colorClass = getLicenseBadgeColor(licenseCode)
const label = DSFA_LICENSE_LABELS[licenseCode] || licenseCode
if (licenseUrl) {
return (
<a
href={licenseUrl}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorClass} hover:opacity-80 transition-opacity`}
>
<Scale className="w-3 h-3" />
{label}
<ExternalLink className="w-2.5 h-2.5" />
</a>
)
}
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs border ${colorClass}`}>
<Scale className="w-3 h-3" />
{label}
</span>
)
}
/**
* Single source item in the attribution list
*/
function SourceItem({
source,
index,
showScore
}: {
source: SourceAttributionProps['sources'][0]
index: number
showScore: boolean
}) {
return (
<li className="text-sm">
<div className="flex items-start gap-2">
<span className="text-slate-400 font-mono text-xs mt-0.5 min-w-[1.5rem]">
{index + 1}.
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{source.sourceUrl ? (
<a
href={source.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 hover:underline font-medium truncate"
>
{source.sourceName}
</a>
) : (
<span className="text-slate-700 font-medium truncate">
{source.sourceName}
</span>
)}
{showScore && source.score !== undefined && (
<span className="text-xs text-slate-400 font-mono">
({(source.score * 100).toFixed(0)}%)
</span>
)}
</div>
<p className="text-xs text-slate-500 mt-0.5 leading-relaxed">
{source.attributionText}
</p>
<div className="mt-1.5">
<LicenseBadge licenseCode={source.licenseCode} />
</div>
</div>
</div>
</li>
)
}
/**
* Compact source badge for inline display
*/
function CompactSourceBadge({
source
}: {
source: SourceAttributionProps['sources'][0]
}) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-slate-100 text-slate-600 border border-slate-200">
<BookOpen className="w-3 h-3" />
{source.sourceCode}
</span>
)
}
/**
* SourceAttribution component - displays source/license information for DSFA RAG results
*
* @example
* ```tsx
* <SourceAttribution
* sources={[
* {
* sourceCode: "WP248",
* sourceName: "WP248 rev.01 - Leitlinien zur DSFA",
* attributionText: "Quelle: WP248 rev.01, Artikel-29-Datenschutzgruppe (2017)",
* licenseCode: "EDPB-LICENSE",
* sourceUrl: "https://ec.europa.eu/newsroom/article29/items/611236/en",
* score: 0.87
* }
* ]}
* showScores
* />
* ```
*/
export function SourceAttribution({
sources,
compact = false,
showScores = false
}: SourceAttributionProps) {
const [isExpanded, setIsExpanded] = useState(!compact)
if (!sources || sources.length === 0) {
return null
}
// Compact mode - just show badges
if (compact && !isExpanded) {
return (
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={() => setIsExpanded(true)}
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700"
>
<Info className="w-3 h-3" />
Quellen ({sources.length})
<ChevronDown className="w-3 h-3" />
</button>
{sources.slice(0, 3).map((source, i) => (
<CompactSourceBadge key={i} source={source} />
))}
{sources.length > 3 && (
<span className="text-xs text-slate-400">+{sources.length - 3}</span>
)}
</div>
)
}
return (
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mt-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-slate-700 flex items-center gap-2">
<BookOpen className="w-4 h-4" />
Quellen & Lizenzen
</h4>
{compact && (
<button
onClick={() => setIsExpanded(false)}
className="text-xs text-slate-400 hover:text-slate-600 flex items-center gap-1"
>
Einklappen
<ChevronUp className="w-3 h-3" />
</button>
)}
</div>
<ul className="mt-3 space-y-3">
{sources.map((source, i) => (
<SourceItem
key={i}
source={source}
index={i}
showScore={showScores}
/>
))}
</ul>
{/* Aggregated license notice */}
{sources.length > 1 && (
<div className="mt-4 pt-3 border-t border-slate-200">
<p className="text-xs text-slate-500">
<strong>Hinweis:</strong> Die angezeigten Inhalte stammen aus {sources.length} verschiedenen Quellen
mit unterschiedlichen Lizenzen. Bitte beachten Sie die jeweiligen Attributionsanforderungen.
</p>
</div>
)}
</div>
)
}
/**
* Inline source reference for use within text
*/
export function InlineSourceRef({
sourceCode,
sourceName,
sourceUrl
}: {
sourceCode: string
sourceName: string
sourceUrl?: string
}) {
if (sourceUrl) {
return (
<a
href={sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-blue-600 hover:text-blue-800 text-sm"
title={sourceName}
>
[{sourceCode}]
<ExternalLink className="w-3 h-3" />
</a>
)
}
return (
<span className="text-slate-600 text-sm" title={sourceName}>
[{sourceCode}]
</span>
)
}
/**
* Attribution footer for generated documents
*/
export function AttributionFooter({
sources,
generatedAt
}: {
sources: SourceAttributionProps['sources']
generatedAt?: Date
}) {
if (!sources || sources.length === 0) {
return null
}
// Group by license
const byLicense = sources.reduce((acc, source) => {
const key = source.licenseCode
if (!acc[key]) acc[key] = []
acc[key].push(source)
return acc
}, {} as Record<string, typeof sources>)
return (
<footer className="mt-8 pt-4 border-t border-slate-200 text-xs text-slate-500">
<h5 className="font-medium text-slate-600 mb-2">Quellennachweis</h5>
<ul className="space-y-1">
{Object.entries(byLicense).map(([licenseCode, licenseSources]) => (
<li key={licenseCode}>
<span className="font-medium">{DSFA_LICENSE_LABELS[licenseCode as DSFALicenseCode]}:</span>{' '}
{licenseSources.map(s => s.sourceName).join(', ')}
</li>
))}
</ul>
{generatedAt && (
<p className="mt-2 text-slate-400">
Generiert am {generatedAt.toLocaleDateString('de-DE')} um {generatedAt.toLocaleTimeString('de-DE')}
</p>
)}
</footer>
)
}
export default SourceAttribution

View File

@@ -201,6 +201,62 @@ export function ThresholdAnalysisSection({ dsfa, onUpdate, isSubmitting }: Thres
{wp248Result.reason}
</p>
</div>
{/* Annex-Trigger: Empfehlung bei >= 2 WP248 Kriterien */}
{wp248Selected.length >= 2 && (
<div className="mt-4 p-4 rounded-xl border bg-indigo-50 border-indigo-200">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center flex-shrink-0 mt-0.5">
<svg className="w-4 h-4 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div>
<p className="font-semibold text-indigo-800 text-sm">Annex mit separater Risikobewertung empfohlen</p>
<p className="text-sm text-indigo-700 mt-1">
Bei {wp248Selected.length} erfuellten WP248-Kriterien wird ein Annex mit detaillierter Risikobewertung empfohlen.
</p>
<div className="mt-2">
<p className="text-xs font-medium text-indigo-700 mb-1">Vorgeschlagene Annex-Scopes basierend auf Ihren Kriterien:</p>
<ul className="text-xs text-indigo-600 space-y-1">
{wp248Selected.includes('scoring_profiling') && (
<li>- Annex: Profiling & Scoring Detailanalyse der Bewertungslogik</li>
)}
{wp248Selected.includes('automated_decision') && (
<li>- Annex: Automatisierte Einzelentscheidung Art. 22 Pruefung</li>
)}
{wp248Selected.includes('systematic_monitoring') && (
<li>- Annex: Systematische Ueberwachung Verhaeltnismaessigkeitspruefung</li>
)}
{wp248Selected.includes('sensitive_data') && (
<li>- Annex: Besondere Datenkategorien Schutzbedarfsanalyse Art. 9</li>
)}
{wp248Selected.includes('large_scale') && (
<li>- Annex: Umfangsanalyse Quantitative Bewertung der Verarbeitung</li>
)}
{wp248Selected.includes('matching_combining') && (
<li>- Annex: Datenzusammenfuehrung Zweckbindungspruefung</li>
)}
{wp248Selected.includes('vulnerable_subjects') && (
<li>- Annex: Schutzbeduerftige Betroffene Verstaerkte Schutzmassnahmen</li>
)}
{wp248Selected.includes('innovative_technology') && (
<li>- Annex: Innovative Technologie Technikfolgenabschaetzung</li>
)}
{wp248Selected.includes('preventing_rights') && (
<li>- Annex: Rechteausuebung Barrierefreiheit der Betroffenenrechte</li>
)}
</ul>
</div>
{aiTriggersSelected.length > 0 && (
<p className="text-xs text-indigo-500 mt-2">
+ KI-Trigger aktiv: Zusaetzlicher Annex fuer KI-Risikobewertung empfohlen (AI Act Konformitaet).
</p>
)}
</div>
</div>
</div>
)}
</div>
{/* Step 2: Art. 35 Abs. 3 Cases */}

View File

@@ -11,6 +11,7 @@ export { DSFASidebar } from './DSFASidebar'
export { StakeholderConsultationSection } from './StakeholderConsultationSection'
export { Art36Warning } from './Art36Warning'
export { ReviewScheduleSection } from './ReviewScheduleSection'
export { SourceAttribution, InlineSourceRef, AttributionFooter } from './SourceAttribution'
// =============================================================================
// DSFA Card Component

View File

@@ -0,0 +1,376 @@
'use client'
import { useMemo, useState, useEffect } from 'react'
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
import { getControlById } from '@/lib/sdk/tom-generator/controls/loader'
interface TOMEditorTabProps {
state: TOMGeneratorState
selectedTOMId: string | null
onUpdateTOM: (tomId: string, updates: Partial<DerivedTOM>) => void
onBack: () => void
}
const STATUS_OPTIONS: { value: DerivedTOM['implementationStatus']; label: string; className: string }[] = [
{ value: 'IMPLEMENTED', label: 'Implementiert', className: 'border-green-300 bg-green-50 text-green-700' },
{ value: 'PARTIAL', label: 'Teilweise implementiert', className: 'border-yellow-300 bg-yellow-50 text-yellow-700' },
{ value: 'NOT_IMPLEMENTED', label: 'Nicht implementiert', className: 'border-red-300 bg-red-50 text-red-700' },
]
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
TECHNICAL: { label: 'Technisch', className: 'bg-blue-100 text-blue-700' },
ORGANIZATIONAL: { label: 'Organisatorisch', className: 'bg-indigo-100 text-indigo-700' },
}
interface VVTActivity {
id: string
name?: string
title?: string
structuredToms?: { category?: string }[]
}
export function TOMEditorTab({ state, selectedTOMId, onUpdateTOM, onBack }: TOMEditorTabProps) {
const tom = useMemo(() => {
if (!selectedTOMId) return null
return state.derivedTOMs.find(t => t.id === selectedTOMId) || null
}, [state.derivedTOMs, selectedTOMId])
const control = useMemo(() => {
if (!tom) return null
return getControlById(tom.controlId)
}, [tom])
const [implementationStatus, setImplementationStatus] = useState<DerivedTOM['implementationStatus']>('NOT_IMPLEMENTED')
const [responsiblePerson, setResponsiblePerson] = useState('')
const [implementationDate, setImplementationDate] = useState('')
const [notes, setNotes] = useState('')
const [linkedEvidence, setLinkedEvidence] = useState<string[]>([])
const [selectedEvidenceId, setSelectedEvidenceId] = useState('')
useEffect(() => {
if (tom) {
setImplementationStatus(tom.implementationStatus)
setResponsiblePerson(tom.responsiblePerson || '')
setImplementationDate(tom.implementationDate ? new Date(tom.implementationDate).toISOString().slice(0, 10) : '')
setNotes(tom.aiGeneratedDescription || '')
setLinkedEvidence(tom.linkedEvidence || [])
}
}, [tom])
const vvtActivities = useMemo(() => {
if (!control) return []
try {
const raw = localStorage.getItem('bp_vvt')
if (!raw) return []
const activities: VVTActivity[] = JSON.parse(raw)
return activities.filter(a =>
a.structuredToms?.some(t => t.category === control.category)
)
} catch {
return []
}
}, [control])
const availableDocuments = useMemo(() => {
return (state.documents || []).filter(
doc => !linkedEvidence.includes(doc.id)
)
}, [state.documents, linkedEvidence])
const linkedDocuments = useMemo(() => {
return linkedEvidence
.map(id => (state.documents || []).find(d => d.id === id))
.filter(Boolean)
}, [state.documents, linkedEvidence])
const evidenceGaps = useMemo(() => {
if (!control?.evidenceRequirements) return []
return control.evidenceRequirements.map(req => {
const hasMatch = (state.documents || []).some(doc =>
linkedEvidence.includes(doc.id) &&
(doc.filename?.toLowerCase().includes(req.toLowerCase()) ||
doc.documentType?.toLowerCase().includes(req.toLowerCase()))
)
return { requirement: req, fulfilled: hasMatch }
})
}, [control, state.documents, linkedEvidence])
const handleSave = () => {
if (!tom) return
onUpdateTOM(tom.id, {
implementationStatus,
responsiblePerson: responsiblePerson || null,
implementationDate: implementationDate ? new Date(implementationDate) : null,
aiGeneratedDescription: notes || null,
linkedEvidence,
})
}
const handleAddEvidence = () => {
if (!selectedEvidenceId) return
setLinkedEvidence(prev => [...prev, selectedEvidenceId])
setSelectedEvidenceId('')
}
const handleRemoveEvidence = (docId: string) => {
setLinkedEvidence(prev => prev.filter(id => id !== docId))
}
if (!selectedTOMId || !tom) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="text-gray-400 mb-4">
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-700 mb-2">Keine TOM ausgewaehlt</h3>
<p className="text-gray-500">Waehlen Sie eine TOM aus der Uebersicht, um sie zu bearbeiten.</p>
</div>
)
}
const typeBadge = TYPE_BADGES[control?.type || 'TECHNICAL'] || TYPE_BADGES.TECHNICAL
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<button
onClick={onBack}
className="text-sm text-purple-600 hover:text-purple-800 font-medium flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck zur Uebersicht
</button>
<button
onClick={handleSave}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
>
Aenderungen speichern
</button>
</div>
{/* TOM Header Card */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-start gap-3 mb-3">
<span className="text-xs font-mono bg-gray-100 text-gray-600 px-2 py-1 rounded">{control?.code || tom.controlId}</span>
<span className={`text-xs px-2 py-1 rounded-full font-medium ${typeBadge.className}`}>
{typeBadge.label}
</span>
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full font-medium">
{control?.category || 'Unbekannt'}
</span>
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">{control?.name?.de || tom.controlId}</h2>
{control?.description?.de && (
<p className="text-sm text-gray-600 leading-relaxed">{control.description.de}</p>
)}
</div>
{/* Implementation Status */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Implementierungsstatus</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{STATUS_OPTIONS.map(opt => (
<label
key={opt.value}
className={`flex items-center gap-3 border rounded-lg p-3 cursor-pointer transition-all ${
implementationStatus === opt.value
? opt.className + ' ring-2 ring-offset-1 ring-current'
: 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'
}`}
>
<input
type="radio"
name="implementationStatus"
value={opt.value}
checked={implementationStatus === opt.value}
onChange={() => setImplementationStatus(opt.value)}
className="sr-only"
/>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${
implementationStatus === opt.value ? 'border-current' : 'border-gray-300'
}`}>
{implementationStatus === opt.value && (
<div className="w-2 h-2 rounded-full bg-current" />
)}
</div>
<span className="text-sm font-medium">{opt.label}</span>
</label>
))}
</div>
</div>
{/* Responsible Person */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Verantwortliche Person</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Umgesetzt von</label>
<input
type="text"
value={responsiblePerson}
onChange={e => setResponsiblePerson(e.target.value)}
placeholder="Name der verantwortlichen Person"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Umsetzungsdatum</label>
<input
type="date"
value={implementationDate}
onChange={e => setImplementationDate(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
</div>
</div>
</div>
{/* Notes */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Anmerkungen</h3>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={4}
placeholder="Anmerkungen zur Umsetzung, Besonderheiten, etc."
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500 resize-y"
/>
</div>
{/* Evidence Section */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Nachweisdokumente</h3>
{linkedDocuments.length > 0 ? (
<div className="space-y-2 mb-4">
{linkedDocuments.map(doc => doc && (
<div key={doc.id} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-sm text-gray-700">{doc.originalName || doc.filename || doc.id}</span>
</div>
<button
onClick={() => handleRemoveEvidence(doc.id)}
className="text-red-500 hover:text-red-700 text-xs font-medium"
>
Entfernen
</button>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400 mb-4">Keine Nachweisdokumente verknuepft.</p>
)}
{availableDocuments.length > 0 && (
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-600 mb-1">Dokument hinzufuegen</label>
<select
value={selectedEvidenceId}
onChange={e => setSelectedEvidenceId(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="">Dokument auswaehlen...</option>
{availableDocuments.map(doc => (
<option key={doc.id} value={doc.id}>{doc.originalName || doc.filename || doc.id}</option>
))}
</select>
</div>
<button
onClick={handleAddEvidence}
disabled={!selectedEvidenceId}
className="bg-purple-600 text-white hover:bg-purple-700 disabled:bg-gray-300 disabled:cursor-not-allowed rounded-lg px-4 py-2 text-sm font-medium transition-colors"
>
Hinzufuegen
</button>
</div>
)}
</div>
{/* Evidence Gaps */}
{evidenceGaps.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Nachweis-Anforderungen</h3>
<div className="space-y-2">
{evidenceGaps.map((gap, idx) => (
<div key={idx} className="flex items-center gap-3">
<div className={`w-5 h-5 rounded flex items-center justify-center flex-shrink-0 ${
gap.fulfilled ? 'bg-green-100 text-green-600' : 'bg-red-50 text-red-400'
}`}>
{gap.fulfilled ? (
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
)}
</div>
<span className={`text-sm ${gap.fulfilled ? 'text-gray-700' : 'text-gray-500'}`}>
{gap.requirement}
</span>
</div>
))}
</div>
</div>
)}
{/* VVT Cross-References */}
{vvtActivities.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">VVT-Querverweise</h3>
<div className="space-y-2">
{vvtActivities.map(activity => (
<div key={activity.id} className="flex items-center gap-2 bg-purple-50 rounded-lg px-3 py-2">
<svg className="w-4 h-4 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<span className="text-sm text-purple-700">{activity.name || activity.title || activity.id}</span>
</div>
))}
</div>
</div>
)}
{/* Framework Mappings */}
{control?.mappings && control.mappings.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-sm font-semibold text-gray-700 mb-4">Framework-Zuordnungen</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{control.mappings.map((mapping, idx) => (
<div key={idx} className="flex items-center gap-2 bg-gray-50 rounded-lg px-3 py-2">
<span className="text-xs font-semibold text-gray-500 uppercase">{mapping.framework}</span>
<span className="text-sm text-gray-700">{mapping.reference}</span>
</div>
))}
</div>
</div>
)}
{/* Bottom Save */}
<div className="flex items-center justify-between pt-2">
<button
onClick={onBack}
className="text-sm text-gray-500 hover:text-gray-700 font-medium"
>
Zurueck zur Uebersicht
</button>
<button
onClick={handleSave}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-6 py-2.5 font-medium transition-colors"
>
Aenderungen speichern
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,328 @@
'use client'
import { useMemo } from 'react'
import { TOMGeneratorState, GapAnalysisResult, DerivedTOM } from '@/lib/sdk/tom-generator/types'
import { getControlById, getAllControls } from '@/lib/sdk/tom-generator/controls/loader'
import {
SDM_GOAL_LABELS,
SDM_GOAL_DESCRIPTIONS,
getSDMCoverageStats,
MODULE_LABELS,
getModuleCoverageStats,
SDMGewaehrleistungsziel,
TOMModuleCategory,
} from '@/lib/sdk/tom-generator/sdm-mapping'
interface TOMGapExportTabProps {
state: TOMGeneratorState
onRunGapAnalysis: () => void
}
function getScoreColor(score: number): string {
if (score >= 75) return 'text-green-600'
if (score >= 50) return 'text-yellow-600'
return 'text-red-600'
}
function getScoreBgColor(score: number): string {
if (score >= 75) return 'bg-green-50 border-green-200'
if (score >= 50) return 'bg-yellow-50 border-yellow-200'
return 'bg-red-50 border-red-200'
}
function getBarColor(score: number): string {
if (score >= 75) return 'bg-green-500'
if (score >= 50) return 'bg-yellow-500'
return 'bg-red-500'
}
function downloadJSON(data: unknown, filename: string) {
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
export function TOMGapExportTab({ state, onRunGapAnalysis }: TOMGapExportTabProps) {
const gap = state.gapAnalysis as GapAnalysisResult | null | undefined
const sdmGoals = useMemo(() => {
const goals = Object.keys(SDM_GOAL_LABELS) as SDMGewaehrleistungsziel[]
const allStats = getSDMCoverageStats(state.derivedTOMs)
return goals.map(key => {
const stats = allStats[key] || { total: 0, implemented: 0, partial: 0, missing: 0 }
const total = stats.total || 1
const percent = Math.round((stats.implemented / total) * 100)
return {
key,
label: SDM_GOAL_LABELS[key],
description: SDM_GOAL_DESCRIPTIONS[key],
stats,
percent,
}
})
}, [state.derivedTOMs])
const modules = useMemo(() => {
const moduleKeys = Object.keys(MODULE_LABELS) as TOMModuleCategory[]
const allStats = getModuleCoverageStats(state.derivedTOMs)
return moduleKeys.map(key => {
const stats = allStats[key] || { total: 0, implemented: 0 }
const total = stats.total || 1
const percent = Math.round((stats.implemented / total) * 100)
return {
key,
label: MODULE_LABELS[key],
stats: { ...stats, partial: 0, missing: total - stats.implemented },
percent,
}
})
}, [state.derivedTOMs])
const handleExportTOMs = () => {
downloadJSON(state.derivedTOMs, `tom-export-${new Date().toISOString().slice(0, 10)}.json`)
}
const handleExportGap = () => {
if (!gap) return
downloadJSON(gap, `gap-analyse-${new Date().toISOString().slice(0, 10)}.json`)
}
return (
<div className="space-y-6">
{/* Gap Analysis */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900">Gap-Analyse</h3>
<button
onClick={onRunGapAnalysis}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-4 py-2 text-sm font-medium transition-colors"
>
Analyse ausfuehren
</button>
</div>
{gap ? (
<div className="space-y-6">
{/* Score Gauge */}
<div className="flex justify-center">
<div className={`rounded-xl border-2 p-8 text-center ${getScoreBgColor(gap.overallScore)}`}>
<div className={`text-5xl font-bold ${getScoreColor(gap.overallScore)}`}>
{gap.overallScore}
</div>
<div className="text-sm text-gray-600 mt-1">von 100 Punkten</div>
</div>
</div>
{/* Missing Controls */}
{gap.missingControls && gap.missingControls.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-red-700 mb-2">
Fehlende Kontrollen ({gap.missingControls.length})
</h4>
<div className="space-y-1">
{gap.missingControls.map((mc, idx) => {
const control = getControlById(mc.controlId)
return (
<div key={idx} className="flex items-center gap-2 bg-red-50 rounded-lg px-3 py-2">
<span className="text-xs font-mono text-red-400">{control?.code || mc.controlId}</span>
<span className="text-sm text-red-700">{control?.name?.de || mc.controlId}</span>
{mc.reason && <span className="text-xs text-red-400 ml-auto">{mc.reason}</span>}
</div>
)
})}
</div>
</div>
)}
{/* Partial Controls */}
{gap.partialControls && gap.partialControls.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-yellow-700 mb-2">
Teilweise implementierte Kontrollen ({gap.partialControls.length})
</h4>
<div className="space-y-1">
{gap.partialControls.map((pc, idx) => {
const control = getControlById(pc.controlId)
return (
<div key={idx} className="flex items-center gap-2 bg-yellow-50 rounded-lg px-3 py-2">
<span className="text-xs font-mono text-yellow-500">{control?.code || pc.controlId}</span>
<span className="text-sm text-yellow-700">{control?.name?.de || pc.controlId}</span>
</div>
)
})}
</div>
</div>
)}
{/* Missing Evidence */}
{gap.missingEvidence && gap.missingEvidence.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-orange-700 mb-2">
Fehlende Nachweise ({gap.missingEvidence.length})
</h4>
<div className="space-y-1">
{gap.missingEvidence.map((item, idx) => {
const control = getControlById(item.controlId)
return (
<div key={idx} className="flex items-center gap-2 bg-orange-50 rounded-lg px-3 py-2">
<svg className="w-4 h-4 text-orange-400 flex-shrink-0" 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-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<span className="text-sm text-orange-700">
{control?.name?.de || item.controlId}: {item.requiredEvidence.join(', ')}
</span>
</div>
)
})}
</div>
</div>
)}
{/* Recommendations */}
{gap.recommendations && gap.recommendations.length > 0 && (
<div>
<h4 className="text-sm font-semibold text-blue-700 mb-2">
Empfehlungen ({gap.recommendations.length})
</h4>
<div className="space-y-1">
{gap.recommendations.map((rec, idx) => (
<div key={idx} className="flex items-start gap-2 bg-blue-50 rounded-lg px-3 py-2">
<svg className="w-4 h-4 text-blue-400 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>
<span className="text-sm text-blue-700">
{typeof rec === 'string' ? rec : (rec as { text?: string; message?: string }).text || (rec as { text?: string; message?: string }).message || JSON.stringify(rec)}
</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="text-center py-8 text-gray-400">
<svg className="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p className="text-sm">Fuehren Sie die Gap-Analyse aus, um Luecken in Ihren TOMs zu identifizieren.</p>
</div>
)}
</div>
{/* SDM Gewaehrleistungsziele */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">SDM Gewaehrleistungsziele</h3>
<div className="space-y-4">
{sdmGoals.map(goal => (
<div key={goal.key}>
<div className="flex items-center justify-between mb-1">
<div>
<span className="text-sm font-medium text-gray-700">{goal.label}</span>
{goal.description && (
<span className="text-xs text-gray-400 ml-2">{goal.description}</span>
)}
</div>
<div className="text-xs text-gray-500">
{goal.stats.implemented}/{goal.stats.total} implementiert
{goal.stats.partial > 0 && ` | ${goal.stats.partial} teilweise`}
{goal.stats.missing > 0 && ` | ${goal.stats.missing} fehlend`}
</div>
</div>
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full flex">
<div
className="bg-green-500 h-full transition-all"
style={{ width: `${goal.percent}%` }}
/>
<div
className="bg-yellow-400 h-full transition-all"
style={{ width: `${goal.stats.total ? Math.round((goal.stats.partial / goal.stats.total) * 100) : 0}%` }}
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Module Coverage */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Modul-Abdeckung</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{modules.map(mod => (
<div key={mod.key} className="border border-gray-200 rounded-lg p-4">
<div className="text-sm font-medium text-gray-700 mb-2">{mod.label}</div>
<div className="flex items-end gap-2 mb-2">
<span className={`text-2xl font-bold ${getScoreColor(mod.percent)}`}>
{mod.percent}%
</span>
<span className="text-xs text-gray-400 mb-1">
({mod.stats.implemented}/{mod.stats.total})
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${getBarColor(mod.percent)}`}
style={{ width: `${mod.percent}%` }}
/>
</div>
{mod.stats.partial > 0 && (
<div className="text-xs text-yellow-600 mt-1">{mod.stats.partial} teilweise</div>
)}
{mod.stats.missing > 0 && (
<div className="text-xs text-red-500 mt-0.5">{mod.stats.missing} fehlend</div>
)}
</div>
))}
</div>
</div>
{/* Export Section */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Export</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<button
onClick={handleExportTOMs}
disabled={state.derivedTOMs.length === 0}
className="flex flex-col items-center gap-2 border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-8 h-8 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span className="text-sm font-medium text-gray-700">JSON Export</span>
<span className="text-xs text-gray-400">Alle TOMs als JSON</span>
</button>
<button
onClick={handleExportGap}
disabled={!gap}
className="flex flex-col items-center gap-2 border border-gray-200 rounded-lg p-4 hover:border-purple-300 hover:bg-purple-50 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-8 h-8 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span className="text-sm font-medium text-gray-700">Gap-Analyse Export</span>
<span className="text-xs text-gray-400">Analyseergebnis als JSON</span>
</button>
<div className="flex flex-col items-center gap-2 border border-dashed border-gray-300 rounded-lg p-4 bg-gray-50">
<svg className="w-8 h-8 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
</svg>
<span className="text-sm font-medium text-gray-500">Vollstaendiger Export (ZIP)</span>
<span className="text-xs text-gray-400 text-center">
Nutzen Sie den TOM Generator fuer den vollstaendigen Export mit DOCX/PDF
</span>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,267 @@
'use client'
import { useMemo, useState } from 'react'
import { DerivedTOM, TOMGeneratorState } from '@/lib/sdk/tom-generator/types'
import { getControlById, getControlsByCategory, getAllCategories } from '@/lib/sdk/tom-generator/controls/loader'
import { SDM_GOAL_LABELS, getSDMCoverageStats, SDMGewaehrleistungsziel } from '@/lib/sdk/tom-generator/sdm-mapping'
interface TOMOverviewTabProps {
state: TOMGeneratorState
onSelectTOM: (tomId: string) => void
onStartGenerator: () => void
}
const STATUS_BADGES: Record<string, { label: string; className: string }> = {
IMPLEMENTED: { label: 'Implementiert', className: 'bg-green-100 text-green-700' },
PARTIAL: { label: 'Teilweise', className: 'bg-yellow-100 text-yellow-700' },
NOT_IMPLEMENTED: { label: 'Fehlend', className: 'bg-red-100 text-red-700' },
}
const TYPE_BADGES: Record<string, { label: string; className: string }> = {
TECHNICAL: { label: 'Technisch', className: 'bg-blue-100 text-blue-700' },
ORGANIZATIONAL: { label: 'Organisatorisch', className: 'bg-indigo-100 text-indigo-700' },
}
const SCHUTZZIELE: { key: SDMGewaehrleistungsziel; label: string }[] = [
{ key: 'Vertraulichkeit', label: 'Vertraulichkeit' },
{ key: 'Integritaet', label: 'Integritaet' },
{ key: 'Verfuegbarkeit', label: 'Verfuegbarkeit' },
{ key: 'Nichtverkettung', label: 'Nichtverkettung' },
]
export function TOMOverviewTab({ state, onSelectTOM, onStartGenerator }: TOMOverviewTabProps) {
const [categoryFilter, setCategoryFilter] = useState<string>('ALL')
const [typeFilter, setTypeFilter] = useState<string>('ALL')
const [statusFilter, setStatusFilter] = useState<string>('ALL')
const [applicabilityFilter, setApplicabilityFilter] = useState<string>('ALL')
const categories = useMemo(() => getAllCategories(), [])
const stats = useMemo(() => {
const toms = state.derivedTOMs
return {
total: toms.length,
implemented: toms.filter(t => t.implementationStatus === 'IMPLEMENTED').length,
partial: toms.filter(t => t.implementationStatus === 'PARTIAL').length,
missing: toms.filter(t => t.implementationStatus === 'NOT_IMPLEMENTED').length,
}
}, [state.derivedTOMs])
const sdmStats = useMemo(() => {
const allStats = getSDMCoverageStats(state.derivedTOMs)
return SCHUTZZIELE.map(sz => ({
...sz,
stats: allStats[sz.key] || { total: 0, implemented: 0, partial: 0, missing: 0 },
}))
}, [state.derivedTOMs])
const filteredTOMs = useMemo(() => {
let toms = state.derivedTOMs
if (categoryFilter !== 'ALL') {
const categoryControlIds = getControlsByCategory(categoryFilter).map(c => c.id)
toms = toms.filter(t => categoryControlIds.includes(t.controlId))
}
if (typeFilter !== 'ALL') {
toms = toms.filter(t => {
const ctrl = getControlById(t.controlId)
return ctrl?.type === typeFilter
})
}
if (statusFilter !== 'ALL') {
toms = toms.filter(t => t.implementationStatus === statusFilter)
}
if (applicabilityFilter !== 'ALL') {
toms = toms.filter(t => t.applicability === applicabilityFilter)
}
return toms
}, [state.derivedTOMs, categoryFilter, typeFilter, statusFilter, applicabilityFilter])
if (state.derivedTOMs.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="text-gray-400 mb-4">
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-700 mb-2">Keine TOMs vorhanden</h3>
<p className="text-gray-500 mb-6 max-w-md">
Starten Sie den TOM Generator, um technische und organisatorische Massnahmen basierend auf Ihrem Verarbeitungsverzeichnis abzuleiten.
</p>
<button
onClick={onStartGenerator}
className="bg-purple-600 text-white hover:bg-purple-700 rounded-lg px-6 py-3 font-medium transition-colors"
>
TOM Generator starten
</button>
</div>
)
}
return (
<div className="space-y-6">
{/* Stats Row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
<div className="text-sm text-gray-500 mt-1">Gesamt TOMs</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-3xl font-bold text-green-600">{stats.implemented}</div>
<div className="text-sm text-gray-500 mt-1">Implementiert</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-3xl font-bold text-yellow-600">{stats.partial}</div>
<div className="text-sm text-gray-500 mt-1">Teilweise</div>
</div>
<div className="bg-white rounded-xl border border-gray-200 p-6 text-center">
<div className="text-3xl font-bold text-red-600">{stats.missing}</div>
<div className="text-sm text-gray-500 mt-1">Fehlend</div>
</div>
</div>
{/* Art. 32 Schutzziele */}
<div>
<h3 className="text-sm font-semibold text-gray-700 mb-3">Art. 32 DSGVO Schutzziele</h3>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{sdmStats.map(sz => {
const total = sz.stats.total || 1
const implPercent = Math.round((sz.stats.implemented / total) * 100)
const partialPercent = Math.round((sz.stats.partial / total) * 100)
return (
<div key={sz.key} className="bg-white rounded-xl border border-gray-200 p-4">
<div className="text-sm font-medium text-gray-700 mb-2">{sz.label}</div>
<div className="flex items-center gap-2 mb-1">
<div className="flex-1 h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full flex">
<div
className="bg-green-500 h-full"
style={{ width: `${implPercent}%` }}
/>
<div
className="bg-yellow-400 h-full"
style={{ width: `${partialPercent}%` }}
/>
</div>
</div>
</div>
<div className="text-xs text-gray-500">
{sz.stats.implemented}/{sz.stats.total} implementiert
</div>
</div>
)
})}
</div>
</div>
{/* Filter Controls */}
<div className="bg-white rounded-xl border border-gray-200 p-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Kategorie</label>
<select
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="ALL">Alle Kategorien</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Typ</label>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="ALL">Alle</option>
<option value="TECHNICAL">Technisch</option>
<option value="ORGANIZATIONAL">Organisatorisch</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Status</label>
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="ALL">Alle</option>
<option value="IMPLEMENTED">Implementiert</option>
<option value="PARTIAL">Teilweise</option>
<option value="NOT_IMPLEMENTED">Fehlend</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Anwendbarkeit</label>
<select
value={applicabilityFilter}
onChange={e => setApplicabilityFilter(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
<option value="ALL">Alle</option>
<option value="REQUIRED">Erforderlich</option>
<option value="RECOMMENDED">Empfohlen</option>
<option value="OPTIONAL">Optional</option>
</select>
</div>
</div>
</div>
{/* TOM Card Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredTOMs.map(tom => {
const control = getControlById(tom.controlId)
const statusBadge = STATUS_BADGES[tom.implementationStatus] || STATUS_BADGES.NOT_IMPLEMENTED
const typeBadge = TYPE_BADGES[control?.type || 'TECHNICAL'] || TYPE_BADGES.TECHNICAL
const evidenceCount = tom.linkedEvidence?.length || 0
return (
<button
key={tom.id}
onClick={() => onSelectTOM(tom.id)}
className="bg-white rounded-xl border border-gray-200 p-5 text-left hover:border-purple-300 hover:shadow-md transition-all group"
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-mono text-gray-400">{control?.code || tom.controlId}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${statusBadge.className}`}>
{statusBadge.label}
</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${typeBadge.className}`}>
{typeBadge.label}
</span>
</div>
{evidenceCount > 0 && (
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
{evidenceCount} Nachweise
</span>
)}
</div>
<h4 className="text-sm font-semibold text-gray-800 group-hover:text-purple-700 transition-colors mb-1">
{control?.name?.de || tom.controlId}
</h4>
<div className="text-xs text-gray-400">
{control?.category || 'Unbekannte Kategorie'}
</div>
</button>
)
})}
</div>
{filteredTOMs.length === 0 && state.derivedTOMs.length > 0 && (
<div className="text-center py-10 text-gray-500">
<p>Keine TOMs entsprechen den aktuellen Filterkriterien.</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,3 @@
export { TOMOverviewTab } from './TOMOverviewTab'
export { TOMEditorTab } from './TOMEditorTab'
export { TOMGapExportTab } from './TOMGapExportTab'