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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user