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