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:
312
admin-v2/components/companion/CompanionDashboard.tsx
Normal file
312
admin-v2/components/companion/CompanionDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
admin-v2/components/companion/ModeToggle.tsx
Normal file
61
admin-v2/components/companion/ModeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
admin-v2/components/companion/companion-mode/EventsCard.tsx
Normal file
173
admin-v2/components/companion/companion-mode/EventsCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
203
admin-v2/components/companion/companion-mode/PhaseTimeline.tsx
Normal file
203
admin-v2/components/companion/companion-mode/PhaseTimeline.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
admin-v2/components/companion/companion-mode/StatsGrid.tsx
Normal file
114
admin-v2/components/companion/companion-mode/StatsGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
170
admin-v2/components/companion/companion-mode/SuggestionList.tsx
Normal file
170
admin-v2/components/companion/companion-mode/SuggestionList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
admin-v2/components/companion/index.ts
Normal file
24
admin-v2/components/companion/index.ts
Normal 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'
|
||||
153
admin-v2/components/companion/lesson-mode/HomeworkSection.tsx
Normal file
153
admin-v2/components/companion/lesson-mode/HomeworkSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
admin-v2/components/companion/lesson-mode/LessonActiveView.tsx
Normal file
172
admin-v2/components/companion/lesson-mode/LessonActiveView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
209
admin-v2/components/companion/lesson-mode/LessonEndedView.tsx
Normal file
209
admin-v2/components/companion/lesson-mode/LessonEndedView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
269
admin-v2/components/companion/lesson-mode/LessonStartForm.tsx
Normal file
269
admin-v2/components/companion/lesson-mode/LessonStartForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
194
admin-v2/components/companion/lesson-mode/QuickActionsBar.tsx
Normal file
194
admin-v2/components/companion/lesson-mode/QuickActionsBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
146
admin-v2/components/companion/lesson-mode/ReflectionSection.tsx
Normal file
146
admin-v2/components/companion/lesson-mode/ReflectionSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
220
admin-v2/components/companion/lesson-mode/VisualPieTimer.tsx
Normal file
220
admin-v2/components/companion/lesson-mode/VisualPieTimer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
201
admin-v2/components/companion/modals/FeedbackModal.tsx
Normal file
201
admin-v2/components/companion/modals/FeedbackModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
280
admin-v2/components/companion/modals/OnboardingModal.tsx
Normal file
280
admin-v2/components/companion/modals/OnboardingModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
admin-v2/components/companion/modals/SettingsModal.tsx
Normal file
248
admin-v2/components/companion/modals/SettingsModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
442
admin-v2/components/sdk/ComplianceAdvisorWidget.tsx
Normal file
442
admin-v2/components/sdk/ComplianceAdvisorWidget.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
362
admin-v2/components/sdk/compliance-scope/ScopeDecisionTab.tsx
Normal file
362
admin-v2/components/sdk/compliance-scope/ScopeDecisionTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
334
admin-v2/components/sdk/compliance-scope/ScopeExportTab.tsx
Normal file
334
admin-v2/components/sdk/compliance-scope/ScopeExportTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
267
admin-v2/components/sdk/compliance-scope/ScopeOverviewTab.tsx
Normal file
267
admin-v2/components/sdk/compliance-scope/ScopeOverviewTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
410
admin-v2/components/sdk/compliance-scope/ScopeWizardTab.tsx
Normal file
410
admin-v2/components/sdk/compliance-scope/ScopeWizardTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
admin-v2/components/sdk/compliance-scope/index.ts
Normal file
4
admin-v2/components/sdk/compliance-scope/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ScopeOverviewTab } from './ScopeOverviewTab'
|
||||
export { ScopeWizardTab } from './ScopeWizardTab'
|
||||
export { ScopeDecisionTab } from './ScopeDecisionTab'
|
||||
export { ScopeExportTab } from './ScopeExportTab'
|
||||
320
admin-v2/components/sdk/dsfa/SourceAttribution.tsx
Normal file
320
admin-v2/components/sdk/dsfa/SourceAttribution.tsx
Normal 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
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
|
||||
376
admin-v2/components/sdk/tom-dashboard/TOMEditorTab.tsx
Normal file
376
admin-v2/components/sdk/tom-dashboard/TOMEditorTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
328
admin-v2/components/sdk/tom-dashboard/TOMGapExportTab.tsx
Normal file
328
admin-v2/components/sdk/tom-dashboard/TOMGapExportTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
267
admin-v2/components/sdk/tom-dashboard/TOMOverviewTab.tsx
Normal file
267
admin-v2/components/sdk/tom-dashboard/TOMOverviewTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
admin-v2/components/sdk/tom-dashboard/index.ts
Normal file
3
admin-v2/components/sdk/tom-dashboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { TOMOverviewTab } from './TOMOverviewTab'
|
||||
export { TOMEditorTab } from './TOMEditorTab'
|
||||
export { TOMGapExportTab } from './TOMGapExportTab'
|
||||
Reference in New Issue
Block a user