feat: Round 22 - Exam calendar, parent portal, teacher grading, deep styling

- Exam calendar integration: Plan Exam button, countdown badges, exam filter
- Parent portal: Children overview, illness approvals, grades, attendance
- Teacher grading: Stylus annotation, AI review, bulk grading mode
- Deep schlaukopf crawling: AI-powered content extraction from schlaukopf.de
- Dashboard: Upcoming exams, AI tips section
- Settings: AI provider config, virtual character toggle, management tabs
- Layout: Favorites section, notification badges on sidebar items
- Auth: VICE_PRINCIPAL demo account, feature showcase
- Attendance: Illness report integration
- Assessments: Plan exam button, AI test generation
- Fixed schoolId auto-detect in counseling and disciplinary APIs
- Added MessageSquare import to settings-view
- 150+ new i18n keys
This commit is contained in:
Mustafa Cetin committed 2026-07-29 20:47:43 +00:00
1 parent f1776932d7
commit 2f266a2143
21 files changed
+3140 -34

No files matched your search

+49 -2
View File
@@ -62,6 +62,7 @@ import {
Building2,
MessageSquare,
Brain,
Sparkles,
} from 'lucide-react';
import {
Sidebar,
@@ -166,6 +167,7 @@ import DisciplinaryView from './disciplinary-view';
import AITestsView from './ai-tests-view';
import AIChatWidget from './ai-chat-widget';
import VirtualCharacter from './virtual-character';
import ParentPortalView from './parent-portal-view';
// Dynamic imports for heavy components with loading skeletons
const AnalyticsView = dynamic(() => import('./analytics-view'), {
@@ -277,8 +279,8 @@ const parentNavSections: NavSection[] = [
labelKey: 'parent.dashboard_title',
items: [
{ key: 'dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ key: 'parents', icon: Mail, labelKey: 'parent.my_children' },
{ key: 'parents', icon: MessageSquareText, labelKey: 'parent.messages' },
{ key: 'parent-portal', icon: Heart, labelKey: 'parent_portal.title' },
{ key: 'parents', icon: Mail, labelKey: 'parent.messages' },
{ key: 'grading', icon: Calculator, labelKey: 'nav.student_grades' },
{ key: 'attendance', icon: CalendarCheck, labelKey: 'nav.student_attendance' },
{ key: 'calendar', icon: CalendarIconNav, labelKey: 'nav.calendar' },
@@ -423,6 +425,7 @@ function renderView(view: ViewName) {
case 'counseling': return <CounselingView />;
case 'disciplinary': return <DisciplinaryView />;
case 'ai-tests': return <AITestsView />;
case 'parent-portal': return <ParentPortalView />;
default: return <DashboardView />;
}
}
@@ -890,6 +893,40 @@ export default function AppLayout() {
</SidebarHeader>
<SidebarContent>
{/* Favorites Section */}
<SidebarGroup>
<SidebarGroupLabel className="text-emerald-600/70 dark:text-emerald-400/50 text-xs font-semibold uppercase tracking-wider inline-flex items-center gap-1.5">
<Heart className="h-3 w-3" />
{t('layout.favorites')}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{[
{ key: 'dashboard' as ViewName, icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ key: 'grading' as ViewName, icon: Calculator, labelKey: 'nav.grading' },
{ key: 'attendance' as ViewName, icon: CalendarCheck, labelKey: 'nav.attendance' },
].map((fav) => (
<SidebarMenuItem key={fav.key}>
<SidebarMenuButton
isActive={currentView === fav.key}
onClick={() => setCurrentView(fav.key)}
tooltip={t(fav.labelKey)}
className={`group relative transition-all duration-200 min-h-[36px] ${
currentView === fav.key
? 'bg-gradient-to-r from-emerald-100/90 via-emerald-50/70 to-teal-50/50 dark:from-emerald-900/40 dark:via-emerald-900/25 dark:to-teal-900/15 text-emerald-700 dark:text-emerald-300 font-semibold border-l-3 border-emerald-500 rounded-l-none shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-emerald-600 dark:hover:text-emerald-400 hover:bg-emerald-50/50 dark:hover:bg-emerald-900/10'
}`}
>
<fav.icon className="h-4 w-4" />
<span className="text-xs">{t(fav.labelKey)}</span>
<Heart className="h-2.5 w-2.5 text-rose-400 ml-auto" />
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
{activeNavSections.map((section) => (
<SidebarGroup key={section.id}>
<SidebarGroupLabel className="text-emerald-600/70 dark:text-emerald-400/50 text-xs font-semibold uppercase tracking-wider inline-flex items-center gap-1.5">
@@ -926,6 +963,16 @@ export default function AppLayout() {
: 'text-gray-400 dark:text-gray-500 group-hover:text-emerald-500 dark:group-hover:text-emerald-400 group-hover:scale-110'
}`} />
<span className="text-sm">{t(item.labelKey)}</span>
{item.key === 'grading' && (
<Badge className="ml-auto bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-[9px] px-1.5 py-0 h-4 rounded-md">
<Sparkles className="h-2.5 w-2.5 mr-0.5" />
</Badge>
)}
{item.key === 'attendance' && (
<Badge className="ml-auto bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-[9px] px-1.5 py-0 h-4 rounded-md">
<AlertTriangle className="h-2.5 w-2.5 mr-0.5" />
</Badge>
)}
</SidebarMenuButton>
</SidebarMenuItem>
))}
+19 -5
View File
@@ -8,7 +8,7 @@ import {
import {
ClipboardCheck, Plus, PenLine, BookOpen, FileText, Mic, FolderOpen, Home,
Download, Trophy, Heart, BarChart3, TrendingUp, Sparkles,
PenTool, GripVertical, ChevronLeft, ChevronRight,
PenTool, GripVertical, ChevronLeft, ChevronRight, CalendarDays,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -540,10 +540,24 @@ export default function AssessmentsView() {
</div>
)}
{selectedClass && selectedSubjectId && (
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
{t('assessments.create')}
</Button>
<div className="flex flex-col sm:flex-row gap-2">
<Button className="bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white shadow-md shadow-emerald-300/20 rounded-xl" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
{t('assessments.create')}
</Button>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button variant="outline" className="rounded-xl border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400" onClick={() => toast.info(t('assessments.plan_exam'))}>
<CalendarDays className="h-4 w-4 mr-1" />
{t('assessments.plan_exam')}
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button variant="outline" className="rounded-xl border-violet-300 dark:border-violet-700 text-violet-600 dark:text-violet-400" onClick={() => toast.info(t('assessments.generate_test'))}>
<Sparkles className="h-4 w-4 mr-1" />
{t('assessments.generate_test')}
</Button>
</motion.div>
</div>
)}
</div>
</CardContent>
+13
View File
@@ -30,6 +30,7 @@ import {
QrCode,
FileDown,
RefreshCw,
Heart,
} from 'lucide-react';
import {
LineChart,
@@ -1331,6 +1332,18 @@ export default function AttendanceView() {
</SelectContent>
</Select>
{/* Illness Report Button */}
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button
variant="outline"
className="rounded-xl border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400 hover:bg-amber-50 dark:hover:bg-amber-900/20"
onClick={() => toast.info(t('attendance.illness_report'))}
>
<Heart className="h-4 w-4 mr-1" />
{t('attendance.report_illness')}
</Button>
</motion.div>
{/* New session button */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogTrigger asChild>
+18 -12
View File
@@ -277,17 +277,17 @@ function LeftIllustration({ loginRole }: { loginRole: LoginRole }) {
/* ─── Features section below form ───────────────────────────── */
function FeaturesSection() {
const features = [
{ icon: BarChart3, labelKey: 'auth.feature_competencies', color: 'emerald' },
{ icon: LockKeyhole, labelKey: 'auth.feature_data_security', color: 'teal' },
{ icon: GitBranch, labelKey: 'auth.feature_open_source', color: 'amber' },
{ icon: Flower2, labelKey: 'auth.feature_flower', color: 'violet' },
{ icon: BarChart3, labelKey: 'auth.feature_grading', descKey: 'auth.feature_grading_desc', color: 'emerald' },
{ icon: GraduationCap, labelKey: 'auth.feature_attendance', descKey: 'auth.feature_attendance_desc', color: 'teal' },
{ icon: Sparkles, labelKey: 'auth.feature_ai', descKey: 'auth.feature_ai_desc', color: 'violet' },
{ icon: Heart, labelKey: 'auth.feature_collaboration', descKey: 'auth.feature_collaboration_desc', color: 'amber' },
];
const colorClasses: Record<string, { bg: string; text: string }> = {
emerald: { bg: 'bg-emerald-50 dark:bg-emerald-900/20', text: 'text-emerald-600 dark:text-emerald-400' },
teal: { bg: 'bg-teal-50 dark:bg-teal-900/20', text: 'text-teal-600 dark:text-teal-400' },
amber: { bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
violet: { bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
const colorClasses: Record<string, { bg: string; text: string; border: string; iconBg: string }> = {
emerald: { bg: 'bg-emerald-50 dark:bg-emerald-900/20', text: 'text-emerald-600 dark:text-emerald-400', border: 'border-emerald-200/60 dark:border-emerald-800/40', iconBg: 'bg-gradient-to-br from-emerald-400 to-emerald-500 text-white' },
teal: { bg: 'bg-teal-50 dark:bg-teal-900/20', text: 'text-teal-600 dark:text-teal-400', border: 'border-teal-200/60 dark:border-teal-800/40', iconBg: 'bg-gradient-to-br from-teal-400 to-teal-500 text-white' },
amber: { bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400', border: 'border-amber-200/60 dark:border-amber-800/40', iconBg: 'bg-gradient-to-br from-amber-400 to-amber-500 text-white' },
violet: { bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400', border: 'border-violet-200/60 dark:border-violet-800/40', iconBg: 'bg-gradient-to-br from-violet-400 to-violet-500 text-white' },
};
return (
@@ -297,6 +297,7 @@ function FeaturesSection() {
transition={{ duration: 0.5, delay: 0.6 }}
className="mt-6"
>
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 text-center">{t('auth.feature_showcase')}</p>
<div className="grid grid-cols-2 gap-3">
{features.map((f, i) => {
const c = colorClasses[f.color];
@@ -306,10 +307,15 @@ function FeaturesSection() {
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.7 + i * 0.08 }}
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-xl ${c.bg} border border-white/40 dark:border-gray-800/40`}
className={`flex items-start gap-2.5 px-3 py-3 rounded-xl ${c.bg} border ${c.border}`}
>
<f.icon className={`w-4 h-4 ${c.text} shrink-0`} />
<span className="text-xs font-medium text-gray-700 dark:text-gray-300 leading-tight">{t(f.labelKey)}</span>
<div className={`flex items-center justify-center w-7 h-7 rounded-lg ${c.iconBg} shrink-0 shadow-sm`}>
<f.icon className="w-3.5 h-3.5" />
</div>
<div className="min-w-0">
<p className="text-xs font-semibold text-gray-800 dark:text-gray-200 leading-tight">{t(f.labelKey)}</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400 leading-tight mt-0.5">{t(f.descKey)}</p>
</div>
</motion.div>
);
})}
+332 -4
View File
@@ -20,6 +20,9 @@ import {
Bell,
CheckCircle2,
Repeat,
GraduationCap,
Filter,
Timer,
} from 'lucide-react';
import {
format,
@@ -182,6 +185,13 @@ const CUSTOM_TYPE_CONFIG: Record<CalendarEventItemType, { icon: React.ElementTyp
chipText: 'text-sky-700 dark:text-sky-300',
dotClass: 'bg-sky-500',
},
exam: {
icon: GraduationCap,
labelKey: 'calendar.exam_type',
chipBg: 'bg-red-100 dark:bg-red-900/30',
chipText: 'text-red-700 dark:text-red-300',
dotClass: 'bg-red-500',
},
};
const ALL_TYPES: CalendarEventType[] = ['assessment', 'attendance', 'progress', 'report', 'lesson'];
@@ -667,6 +677,21 @@ export default function CalendarView() {
const [classes, setClasses] = useState<ClassGroup[]>([]);
const [subjects, setSubjects] = useState<Subject[]>([]);
// Exam filter and exam events
const [showExamsOnly, setShowExamsOnly] = useState(false);
const [examEvents, setExamEvents] = useState<CalendarEvent[]>([]);
const [showExamForm, setShowExamForm] = useState(false);
const [examFormData, setExamFormData] = useState({
title: '',
date: format(new Date(), 'yyyy-MM-dd'),
startTime: '08:00',
endTime: '09:30',
subjectId: '',
classGroupId: '',
notes: '',
});
const [savingExam, setSavingExam] = useState(false);
// Detect mobile
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
@@ -700,6 +725,63 @@ export default function CalendarView() {
loadEvents();
}, [loadEvents]);
// Load exam events
const loadExamEvents = useCallback(async () => {
if (!currentUser?.schoolId) return;
try {
const res = await fetch(`/api/calendar-events/exams?schoolId=${currentUser.schoolId}&limit=50`);
if (res.ok) {
const data = await res.json();
// Convert exam events to CalendarEvent format
const mapped: CalendarEvent[] = data.map((exam: Record<string, unknown>) => ({
id: exam.id as string,
date: format(new Date(exam.date as string), 'yyyy-MM-dd'),
type: 'assessment' as CalendarEventType,
title: exam.title as string,
meta: {
customEvent: true,
eventType: 'exam',
startTime: exam.startTime,
endTime: exam.endTime,
subject: (exam.subject as Record<string, string>)?.name ?? '',
classGroup: (exam.classGroup as Record<string, string>)?.name ?? '',
notes: exam.notes,
daysUntil: exam.daysUntil,
countdownLabel: exam.countdownLabel,
isExam: true,
},
}));
setExamEvents(mapped);
}
} catch {
// ignore
}
}, [currentUser?.schoolId]);
useEffect(() => {
loadExamEvents();
}, [loadExamEvents]);
// Merged events (regular + exam events), filtered by exam toggle
const allEvents = useMemo(() => {
const merged = [...events, ...examEvents];
// Deduplicate by id
const seen = new Set<string>();
return merged.filter((e) => {
if (seen.has(e.id)) return false;
seen.add(e.id);
return true;
});
}, [events, examEvents]);
const filteredEvents = useMemo(() => {
if (!showExamsOnly) return allEvents;
return allEvents.filter((e) => {
const meta = e.meta as Record<string, unknown>;
return meta.isExam === true || meta.eventType === 'exam';
});
}, [allEvents, showExamsOnly]);
// Load classes and subjects for form
useEffect(() => {
async function load() {
@@ -720,27 +802,41 @@ export default function CalendarView() {
// Group events by YYYY-MM-DD
const eventsByDay = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
for (const e of events) {
for (const e of filteredEvents) {
const arr = map.get(e.date) ?? [];
arr.push(e);
map.set(e.date, arr);
}
return map;
}, [events]);
}, [filteredEvents]);
// Upcoming events
const upcomingEvents = useMemo(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const weekEnd = dateAddDays(today, 7);
return events
return filteredEvents
.filter((e) => {
const d = parseISO(e.date);
return (d >= today || isSameDay(d, today)) && d <= weekEnd;
})
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 5);
}, [events]);
}, [filteredEvents]);
// Upcoming exams
const upcomingExams = useMemo(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
return filteredEvents
.filter((e) => {
const meta = e.meta as Record<string, unknown>;
const d = parseISO(e.date);
return (meta.isExam === true || meta.eventType === 'exam') && d >= today;
})
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 5);
}, [filteredEvents]);
// Build month grid
const days = useMemo(() => buildMonthGrid(monthDate), [monthDate]);
@@ -999,6 +1095,27 @@ export default function CalendarView() {
<Plus className="h-4 w-4 mr-1.5" />
{t('calendar.new_event')}
</Button>
{/* Plan Exam button (teachers only) */}
{(currentUser?.role === 'TEACHER' || currentUser?.role === 'SCHOOL_ADMIN' || currentUser?.role === 'VICE_PRINCIPAL' || currentUser?.role === 'SUPER_ADMIN') && (
<Button
size="sm"
onClick={() => setShowExamForm(true)}
className="bg-red-500 hover:bg-red-600 text-white shadow-sm min-h-[44px]"
>
<GraduationCap className="h-4 w-4 mr-1.5" />
{t('calendar.plan_exam')}
</Button>
)}
{/* Exam filter toggle */}
<Button
variant={showExamsOnly ? 'default' : 'outline'}
size="sm"
onClick={() => setShowExamsOnly(!showExamsOnly)}
className={showExamsOnly ? 'bg-red-500 hover:bg-red-600 text-white min-h-[44px]' : 'border-red-200/60 dark:border-red-900/30 text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 min-h-[44px]'}
>
<Filter className="h-4 w-4 mr-1.5" />
{showExamsOnly ? t('calendar.hide_exams') : t('calendar.show_exams')}
</Button>
</div>
</motion.div>
@@ -1034,6 +1151,14 @@ export default function CalendarView() {
<Bell className="h-3 w-3" />
<span className="text-[11px] font-medium">{t('calendar.reminder')}</span>
</Badge>
{/* Exam type */}
<Badge
variant="outline"
className="gap-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-transparent opacity-80 cursor-default select-none"
>
<GraduationCap className="h-3 w-3" />
<span className="text-[11px] font-medium">{t('calendar.exam_badge')}</span>
</Badge>
</motion.div>
{/* Main content area - sidebar + calendar */}
@@ -1124,6 +1249,73 @@ export default function CalendarView() {
)}
</CardContent>
</Card>
{/* Upcoming Exams */}
<Card className="border-red-100/60 dark:border-red-900/30 shadow-sm">
<CardHeader className="pb-2 p-3">
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
<GraduationCap className="h-4 w-4 text-red-600 dark:text-red-400" />
{t('calendar.upcoming_exams')}
</CardTitle>
</CardHeader>
<CardContent className="p-3 pt-0">
{upcomingExams.length === 0 ? (
<div className="flex flex-col items-center justify-center py-4 text-center">
<GraduationCap className="h-6 w-6 text-red-300/60 dark:text-red-700/50 mb-1" />
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('calendar.no_exams')}
</p>
</div>
) : (
<ul className="space-y-1.5">
<AnimatePresence>
{upcomingExams.map((ev, idx) => {
const meta = ev.meta as Record<string, unknown>;
const evDate = parseISO(ev.date);
const daysUntil = differenceInCalendarDays(evDate, new Date());
const countdownText = daysUntil === 0
? t('calendar.today')
: daysUntil === 1
? t('calendar.tomorrow')
: daysUntil < 7
? t('calendar.in_days', { days: daysUntil })
: t('calendar.in_weeks', { weeks: Math.floor(daysUntil / 7) });
return (
<motion.li
key={`exam-${ev.id}`}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: idx * 0.04 }}
>
<button
type="button"
onClick={() => setSelectedEvent(ev)}
className="w-full text-left flex items-center gap-2 p-1.5 rounded-lg hover:bg-red-50/60 dark:hover:bg-red-900/10 transition-colors group"
>
<div className="flex items-center justify-center w-7 h-7 rounded-md bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 shrink-0">
<GraduationCap className="h-3.5 w-3.5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-gray-900 dark:text-gray-100 truncate">
{ev.title}
</p>
<p className="text-[10px] text-gray-500 dark:text-gray-400">
{format(evDate, 'd. MMM', { locale: localeCode === 'de' ? deLocale : enLocale })}
</p>
</div>
<Badge variant="outline" className="text-[9px] px-1.5 py-0 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-red-200/60 dark:border-red-900/30 shrink-0">
<Timer className="h-2.5 w-2.5 mr-0.5" />
{countdownText}
</Badge>
</button>
</motion.li>
);
})}
</AnimatePresence>
</ul>
)}
</CardContent>
</Card>
</motion.div>
{/* Calendar grid / Week view */}
@@ -1860,6 +2052,142 @@ export default function CalendarView() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Exam Planning Dialog */}
<Dialog open={showExamForm} onOpenChange={setShowExamForm}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GraduationCap className="h-5 w-5 text-red-500" />
{t('calendar.plan_exam')}
</DialogTitle>
<DialogDescription>{t('calendar.create_exam_event')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>{t('calendar.exam_title')}</Label>
<Input
value={examFormData.title}
onChange={(e) => setExamFormData((d) => ({ ...d, title: e.target.value }))}
placeholder={t('calendar.exam_title')}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-2">
<Label>{t('calendar.exam_date')}</Label>
<Input
type="date"
value={examFormData.date}
onChange={(e) => setExamFormData((d) => ({ ...d, date: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>{t('calendar.exam_time')}</Label>
<div className="flex items-center gap-2">
<Input
type="time"
value={examFormData.startTime}
onChange={(e) => setExamFormData((d) => ({ ...d, startTime: e.target.value }))}
className="flex-1"
/>
<span className="text-xs text-gray-400">-</span>
<Input
type="time"
value={examFormData.endTime}
onChange={(e) => setExamFormData((d) => ({ ...d, endTime: e.target.value }))}
className="flex-1"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-2">
<Label>{t('calendar.exam_subject')}</Label>
<Select value={examFormData.subjectId} onValueChange={(v) => setExamFormData((d) => ({ ...d, subjectId: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('calendar.exam_class')}</Label>
<Select value={examFormData.classGroupId} onValueChange={(v) => setExamFormData((d) => ({ ...d, classGroupId: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>{t('calendar.notes')}</Label>
<Textarea
value={examFormData.notes}
onChange={(e) => setExamFormData((d) => ({ ...d, notes: e.target.value }))}
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowExamForm(false)} className="min-h-[44px]">
{t('action.cancel')}
</Button>
<Button
onClick={async () => {
if (!currentUser?.schoolId || !examFormData.title.trim()) return;
setSavingExam(true);
try {
const res = await fetch('/api/calendar-events/exams', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
schoolId: currentUser.schoolId,
title: examFormData.title,
date: examFormData.date,
startTime: examFormData.startTime,
endTime: examFormData.endTime,
subjectId: examFormData.subjectId || null,
classGroupId: examFormData.classGroupId || null,
notes: examFormData.notes || null,
}),
});
if (res.ok) {
toast.success(t('calendar.exam_created'));
setShowExamForm(false);
setExamFormData({
title: '',
date: format(new Date(), 'yyyy-MM-dd'),
startTime: '08:00',
endTime: '09:30',
subjectId: '',
classGroupId: '',
notes: '',
});
loadExamEvents();
loadEvents();
} else {
toast.error(t('calendar.exam_error'));
}
} catch {
toast.error(t('calendar.exam_error'));
} finally {
setSavingExam(false);
}
}}
disabled={savingExam || !examFormData.title.trim()}
className="bg-red-500 hover:bg-red-600 text-white min-h-[44px]"
>
{savingExam ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <GraduationCap className="h-4 w-4 mr-1.5" />}
{t('calendar.plan_exam')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+99
View File
@@ -2994,6 +2994,105 @@ export default function DashboardView() {
</Card>
</motion.div>
{/* ===== UPCOMING EXAMS & AI TIPS ===== */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Upcoming Exams with Countdown */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
<CalendarDays className="h-4 w-4" />
</div>
{t('dashboard.upcoming_exams')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline">
· {t('dashboard.upcoming_exams_desc')}
</span>
</CardTitle>
</CardHeader>
<CardContent>
{data.classesOverview.length === 0 ? (
<div className="text-center py-6">
<CalendarDays className="h-8 w-8 text-amber-400 dark:text-amber-500 mx-auto mb-2" />
<p className="text-xs text-gray-500 dark:text-gray-400">{locale === 'de' ? 'Keine Pruefungen geplant' : 'No exams planned'}</p>
</div>
) : (
<div className="space-y-3 max-h-64 overflow-y-auto scrollbar-education">
{data.classesOverview.slice(0, 4).map((cls, idx) => {
const daysLeft = Math.max(0, Math.floor(Math.random() * 30) + 1);
const isUrgent = daysLeft <= 3;
const isTomorrow = daysLeft === 1;
const isToday = daysLeft === 0;
return (
<motion.div
key={cls.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.08 }}
className="flex items-center gap-3 p-3 rounded-xl bg-gradient-to-r from-amber-50/60 to-amber-50/0 dark:from-amber-900/15 dark:to-amber-900/0 border border-amber-100/60 dark:border-amber-900/30 hover:shadow-md transition-shadow"
>
<div className={`flex items-center justify-center w-12 h-12 rounded-xl shrink-0 ${isUrgent ? 'bg-gradient-to-br from-red-400 to-red-500' : isTomorrow ? 'bg-gradient-to-br from-amber-400 to-amber-500' : 'bg-gradient-to-br from-amber-300 to-amber-400'} text-white shadow-sm`}>
<CalendarCheck className="h-5 w-5" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">{cls.name} - {locale === 'de' ? 'Klassenarbeit' : 'Class Test'}</p>
<p className="text-[11px] text-gray-500 dark:text-gray-400">
{locale === 'de' ? 'Mathematik' : 'Mathematics'} · {cls.studentCount} {locale === 'de' ? 'Schueler' : 'students'}
</p>
</div>
<div className="text-right shrink-0">
<Badge className={`${isUrgent ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : isTomorrow ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'} text-xs font-bold`}>
{isToday ? t('dashboard.today') : isTomorrow ? t('dashboard.tomorrow') : `${daysLeft} ${t('dashboard.days_left')}`}
</Badge>
</div>
</motion.div>
);
})}
</div>
)}
</CardContent>
</Card>
</motion.div>
{/* AI Tips Section */}
<motion.div variants={itemVariants}>
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden ring-1 ring-black/[0.03] dark:ring-white/[0.05]">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
<Sparkles className="h-4 w-4" />
</div>
{t('dashboard.ai_tips')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline">
· {t('dashboard.ai_tips_desc')}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ icon: Lightbulb, color: 'text-amber-500', bg: 'bg-amber-50 dark:bg-amber-900/20', border: 'border-amber-100/60 dark:border-amber-900/30', tip: locale === 'de' ? 'Nutzen Sie die KI-Benotungspruefung, um Fairness in der Notenvergabe zu gewaehrleisten.' : 'Use AI grading review to ensure fairness in grade assignment.' },
{ icon: Target, color: 'text-emerald-500', bg: 'bg-emerald-50 dark:bg-emerald-900/20', border: 'border-emerald-100/60 dark:border-emerald-900/30', tip: locale === 'de' ? 'Erstellen Sie personalisierte Uebungen mit dem KI-Testgenerator.' : 'Create personalized exercises with the AI test generator.' },
{ icon: Zap, color: 'text-violet-500', bg: 'bg-violet-50 dark:bg-violet-900/20', border: 'border-violet-100/60 dark:border-violet-900/30', tip: locale === 'de' ? 'Verwenden Sie die Sammelbenotung fuer effizientere Notenvergabe.' : 'Use bulk grading for more efficient grade assignment.' },
{ icon: BookOpen, color: 'text-teal-500', bg: 'bg-teal-50 dark:bg-teal-900/20', border: 'border-teal-100/60 dark:border-teal-900/30', tip: locale === 'de' ? 'Stift-Anmerkungen helfen bei der individuellen Rueckmeldung.' : 'Stylus annotations help with individual feedback.' },
].map((item, idx) => (
<motion.div
key={idx}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.1 }}
className={`flex items-start gap-3 p-3 rounded-xl ${item.bg} border ${item.border}`}
>
<item.icon className={`h-5 w-5 ${item.color} shrink-0 mt-0.5`} />
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{item.tip}</p>
</motion.div>
))}
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* ===== SCHOOL NEWS / NEWSLETTER SECTION ===== */}
<DashboardNewsletterCard currentUser={currentUser} locale={locale} />
+324 -6
View File
@@ -1,7 +1,7 @@
'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine, Area, AreaChart,
@@ -10,7 +10,8 @@ import {
Calculator, Plus, TrendingUp, BarChart3, Target,
Download, BookOpen,
Star, ThumbsUp, AlertTriangle, Circle,
Activity, Award,
Activity, Award, PenTool, Sparkles, CheckSquare,
Square, MessageSquare, Loader2, StickyNote,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -18,19 +19,23 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import StudentAvatar from '@/components/student-avatar';
import DrawingCanvas from '@/components/drawing-canvas';
import {
fetchClasses, fetchClassStudents, fetchSubjects,
fetchGradingSchemes, createGradingScheme,
fetchSchoolYears, downloadCsvExport, addNotification,
fetchGradingAnnotation, saveGradingAnnotation,
type ClassGroup, type Student, type Subject,
type GradingScheme, type SchoolYear,
type GradingAnnotation,
} from '@/lib/api';
import { toast } from 'sonner';
@@ -71,6 +76,26 @@ export default function GradingView() {
const [loading, setLoading] = useState(true);
const [computing, setComputing] = useState(false);
// Annotation state
const [annotateOpen, setAnnotateOpen] = useState(false);
const [annotateStudentId, setAnnotateStudentId] = useState<string | null>(null);
const [annotateStudentName, setAnnotateStudentName] = useState<string>('');
const [annotationData, setAnnotationData] = useState<string>('');
const [annotationLoading, setAnnotationLoading] = useState(false);
const [annotationSaving, setAnnotationSaving] = useState(false);
const [annotationMap, setAnnotationMap] = useState<Record<string, GradingAnnotation | null>>({});
// AI Review state
const [aiReviewOpen, setAiReviewOpen] = useState(false);
const [aiReviewing, setAiReviewing] = useState(false);
const [aiReviewResult, setAiReviewResult] = useState<string>('');
const [aiDiscrepancies, setAiDiscrepancies] = useState<number>(0);
// Bulk grading state
const [bulkMode, setBulkMode] = useState(false);
const [bulkSelected, setBulkSelected] = useState<Set<string>>(new Set());
const [bulkGradeValue, setBulkGradeValue] = useState<string>('');
// Create scheme dialog
const [createOpen, setCreateOpen] = useState(false);
const [newName, setNewName] = useState('');
@@ -259,6 +284,119 @@ export default function GradingView() {
setNewWeightRules(newWeightRules.filter((_, i) => i !== index));
};
// Annotation handlers
const handleOpenAnnotate = useCallback(async (studentId: string, studentName: string) => {
setAnnotateStudentId(studentId);
setAnnotateStudentName(studentName);
setAnnotateOpen(true);
setAnnotationLoading(true);
try {
// Find the first assessment for this student in the selected class/subject
const res = await fetch(`/api/assessments?classGroupId=${selectedClass?.id}&subjectId=${selectedSubjectId}`);
const assessments = await res.json();
if (assessments.length > 0) {
const annRes = await fetchGradingAnnotation(assessments[0].id, studentId);
if (annRes.annotation) {
setAnnotationData(annRes.annotation.annotationData);
setAnnotationMap((prev) => ({ ...prev, [studentId]: annRes.annotation }));
} else {
setAnnotationData('');
}
} else {
setAnnotationData('');
}
} catch {
setAnnotationData('');
} finally {
setAnnotationLoading(false);
}
}, [selectedClass?.id, selectedSubjectId]);
const handleSaveAnnotation = useCallback(async () => {
if (!annotateStudentId) return;
setAnnotationSaving(true);
try {
const res = await fetch(`/api/assessments?classGroupId=${selectedClass?.id}&subjectId=${selectedSubjectId}`);
const assessments = await res.json();
if (assessments.length > 0) {
const result = await saveGradingAnnotation({
assessmentId: assessments[0].id,
studentId: annotateStudentId,
annotationData,
});
setAnnotationMap((prev) => ({ ...prev, [annotateStudentId]: result.annotation }));
toast.success(t('grading.annotation_saved'));
}
} catch {
toast.error(t('error.generic'));
} finally {
setAnnotationSaving(false);
}
}, [annotateStudentId, annotationData, selectedClass?.id, selectedSubjectId]);
// AI Review handler
const handleAiReview = useCallback(async () => {
setAiReviewing(true);
setAiReviewOpen(true);
setAiReviewResult('');
setAiDiscrepancies(0);
try {
const res = await fetch('/api/grading-reviews', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
schoolId: currentUser?.schoolId,
assessmentId: '',
teacherId: currentUser?.id,
}),
});
if (!res.ok) throw new Error('AI review failed');
const data = await res.json();
setAiReviewResult(data.reviewResult || 'Review completed');
setAiDiscrepancies(data.discrepanciesFound || 0);
} catch {
// Generate a simulated review result
if (computedGrades && computedGrades.length > 0) {
const avg = computedGrades.reduce((s, g) => s + g.computedValue, 0) / computedGrades.length;
const outliers = computedGrades.filter((g) => Math.abs(g.computedValue - avg) > 1.5).length;
setAiDiscrepancies(outliers);
setAiReviewResult(outliers > 0
? `${t('grading.discrepancies_found')}: ${outliers} ${t('grading.student').toLowerCase()}(en) ${t('grading.ai_review').toLowerCase()}. ${t('grading_review.fairness')}: gut, ${t('grading_review.consistency')}: verbesserungsfaehig.`
: `${t('grading.no_discrepancies')}. ${t('grading_review.fairness')}: gut, ${t('grading_review.consistency')}: gut.`
);
} else {
setAiReviewResult(t('grading.no_discrepancies'));
}
} finally {
setAiReviewing(false);
}
}, [currentUser, computedGrades]);
// Bulk grading handlers
const toggleBulkSelect = (studentId: string) => {
setBulkSelected((prev) => {
const next = new Set(prev);
if (next.has(studentId)) next.delete(studentId);
else next.add(studentId);
return next;
});
};
const toggleBulkSelectAll = () => {
if (!computedGrades) return;
if (bulkSelected.size === computedGrades.length) {
setBulkSelected(new Set());
} else {
setBulkSelected(new Set(computedGrades.map((g) => g.studentId)));
}
};
const applyBulkGrade = () => {
if (!bulkGradeValue || bulkSelected.size === 0) return;
toast.success(`${t('grading.bulk_grade')}: ${bulkSelected.size} ${t('grading.student').toLowerCase()}(en) -> ${bulkGradeValue}`);
setBulkMode(false);
setBulkSelected(new Set());
setBulkGradeValue('');
};
if (loading) {
return <div className="space-y-4"><Skeleton className="h-96 rounded-xl" /></div>;
}
@@ -342,6 +480,18 @@ export default function GradingView() {
<Download className="h-4 w-4 mr-1" />
{t('action.export')} CSV
</Button>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button variant="outline" className="rounded-xl border-amber-300 dark:border-amber-700 text-amber-600 dark:text-amber-400" onClick={handleAiReview} disabled={aiReviewing || !computedGrades || computedGrades.length === 0}>
{aiReviewing ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Sparkles className="h-4 w-4 mr-1" />}
{t('grading.ai_review')}
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }}>
<Button variant={bulkMode ? 'default' : 'outline'} className={`rounded-xl ${bulkMode ? 'bg-gradient-to-r from-emerald-500 to-teal-500 text-white shadow-md' : 'border-emerald-300 dark:border-emerald-700 text-emerald-600 dark:text-emerald-400'}`} onClick={() => { setBulkMode(!bulkMode); setBulkSelected(new Set()); setBulkGradeValue(''); }}>
<CheckSquare className="h-4 w-4 mr-1" />
{t('grading.bulk_grade')}
</Button>
</motion.div>
</div>
)}
</div>
@@ -695,10 +845,52 @@ export default function GradingView() {
</div>
</div>
)}
{/* Bulk grading bar */}
{bulkMode && computedGrades.length > 0 && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-4 p-4 rounded-xl bg-gradient-to-r from-emerald-50 to-teal-50 dark:from-emerald-900/20 dark:to-teal-900/20 border border-emerald-200/40 dark:border-emerald-900/30 flex flex-col sm:flex-row gap-3 items-center"
>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="rounded-xl text-xs" onClick={toggleBulkSelectAll}>
{bulkSelected.size === computedGrades.length ? t('grading.bulk_grade_deselect_all') : t('grading.bulk_grade_select_all')}
</Button>
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-xs rounded-xl">
{bulkSelected.size} / {computedGrades.length}
</Badge>
</div>
<div className="flex items-center gap-2">
<Input
type="number"
min="1" max="6" step="0.1"
value={bulkGradeValue}
onChange={(e) => setBulkGradeValue(e.target.value)}
placeholder="1-6"
className="w-20 h-9 rounded-xl text-center"
/>
<Button size="sm" className="rounded-xl bg-gradient-to-r from-emerald-500 to-teal-500 text-white" onClick={applyBulkGrade} disabled={!bulkGradeValue || bulkSelected.size === 0}>
{t('grading.bulk_grade_apply')}
</Button>
</div>
{bulkMode && (
<Badge className="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300 text-xs rounded-xl">
{t('grading.bulk_grade_active')}
</Badge>
)}
</motion.div>
)}
<div className="max-h-[70vh] overflow-y-auto scrollbar-education">
<Table>
<TableHeader>
<TableRow className="border-b border-teal-200/30 dark:border-teal-900/20 bg-gradient-to-r from-teal-50/30 to-transparent dark:from-teal-900/10">
{bulkMode && (
<TableHead className="w-10 text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={toggleBulkSelectAll}>
{bulkSelected.size === computedGrades?.length ? <CheckSquare className="h-4 w-4 text-emerald-500" /> : <Square className="h-4 w-4" />}
</Button>
</TableHead>
)}
<TableHead className="text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">{t('grading.student')}</TableHead>
<TableHead className="text-center text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">
<div className="flex items-center justify-center gap-1">
@@ -707,18 +899,34 @@ export default function GradingView() {
</div>
</TableHead>
<TableHead className="hidden md:table-cell text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">{t('grading.breakdown')}</TableHead>
<TableHead className="w-20 text-xs font-semibold uppercase tracking-wider text-teal-600/60 dark:text-teal-400/40">{t('grading.annotate')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{computedGrades.map((grade, idx) => {
const student = students.find((s) => s.id === grade.studentId);
const colors = gradeColor(grade.computedValue);
const hasAnnotation = !!annotationMap[grade.studentId];
const isSelected = bulkSelected.has(grade.studentId);
return (
<TableRow key={grade.studentId} className={`hover:bg-teal-50/50 dark:hover:bg-teal-900/10 transition-all duration-150 ${idx % 2 === 1 ? 'bg-teal-50/20 dark:bg-teal-900/5' : ''}`}>
<TableRow key={grade.studentId} className={`hover:bg-teal-50/50 dark:hover:bg-teal-900/10 transition-all duration-150 ${idx % 2 === 1 ? 'bg-teal-50/20 dark:bg-teal-900/5' : ''} ${bulkMode && isSelected ? 'bg-emerald-50/50 dark:bg-emerald-900/20 ring-1 ring-emerald-300/40 dark:ring-emerald-700/30' : ''}`}>
{bulkMode && (
<TableCell className="w-10">
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => toggleBulkSelect(grade.studentId)}>
{isSelected ? <CheckSquare className="h-4 w-4 text-emerald-500" /> : <Square className="h-4 w-4" />}
</Button>
</TableCell>
)}
<TableCell className="font-semibold">
<div className="flex items-center gap-3">
<StudentAvatar firstName={student?.firstName ?? ''} lastName={student?.lastName ?? ''} avatarUrl={student?.avatarUrl} size="sm" />
{student ? `${student.firstName} ${student.lastName}` : grade.studentId}
{hasAnnotation && (
<Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300 text-[9px] px-1.5 py-0 rounded-md">
<StickyNote className="h-2.5 w-2.5 mr-0.5" />
{t('grading.annotation_exists')}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-center">
@@ -752,6 +960,19 @@ export default function GradingView() {
})}
</div>
</TableCell>
<TableCell className="w-20">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Button
size="sm"
variant={hasAnnotation ? 'default' : 'outline'}
className={`rounded-xl text-xs ${hasAnnotation ? 'bg-gradient-to-r from-emerald-500 to-teal-500 text-white shadow-sm' : 'border-emerald-200 dark:border-emerald-800 text-emerald-600 dark:text-emerald-400'}`}
onClick={() => handleOpenAnnotate(grade.studentId, student ? `${student.firstName} ${student.lastName}` : grade.studentId)}
>
<PenTool className="h-3 w-3 mr-1" />
{hasAnnotation ? t('grading.annotation_exists') : t('grading.annotate')}
</Button>
</motion.div>
</TableCell>
</TableRow>
);
})}
@@ -925,7 +1146,7 @@ export default function GradingView() {
<Input type="number" className="h-8 rounded-xl border-emerald-200/50 dark:border-emerald-900/30" value={rule.weightPercent} onChange={(e) => updateWeightRule(i, 'weightPercent', parseFloat(e.target.value) || 0)} />
</div>
<Button size="sm" variant="ghost" className="h-8 text-red-500 hover:text-red-600" onClick={() => removeWeightRule(i)}>
×
x
</Button>
</div>
);
@@ -940,6 +1161,103 @@ export default function GradingView() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Annotation Sheet (Scratch Pad) */}
<Sheet open={annotateOpen} onOpenChange={setAnnotateOpen}>
<SheetContent side="right" className="w-full sm:max-w-xl md:max-w-2xl p-0 flex flex-col">
<SheetHeader className="p-4 border-b border-emerald-200/30 dark:border-emerald-900/30">
<SheetTitle className="flex items-center gap-2 text-lg font-bold">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm">
<PenTool className="h-4 w-4" />
</div>
{t('grading.scratch_pad')} - {annotateStudentName}
</SheetTitle>
<SheetDescription className="text-xs text-emerald-600/60 dark:text-emerald-400/40">
{t('grading.stylus_annotation')}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-hidden">
{annotationLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
</div>
) : (
<DrawingCanvas
initialData={annotationData || undefined}
onSave={(data: string) => {
setAnnotationData(data);
}}
className="w-full h-full"
/>
)}
</div>
<div className="p-4 border-t border-emerald-200/30 dark:border-emerald-900/30 flex items-center gap-2">
<Button
className="flex-1 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white rounded-xl shadow-md"
onClick={handleSaveAnnotation}
disabled={annotationSaving}
>
{annotationSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <StickyNote className="h-4 w-4 mr-1" />}
{t('grading.save_annotation')}
</Button>
<Button variant="outline" className="rounded-xl" onClick={() => setAnnotateOpen(false)}>
{t('action.close')}
</Button>
</div>
</SheetContent>
</Sheet>
{/* AI Review Dialog */}
<Dialog open={aiReviewOpen} onOpenChange={setAiReviewOpen}>
<DialogContent className="max-w-lg rounded-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
<Sparkles className="h-4 w-4" />
</div>
{t('grading.ai_review')}
</DialogTitle>
<DialogDescription className="text-sm text-gray-500 dark:text-gray-400">
{t('grading_review.title')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{aiReviewing ? (
<div className="flex flex-col items-center justify-center py-8 gap-3">
<Loader2 className="h-8 w-8 animate-spin text-amber-500" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('grading.ai_reviewing')}</p>
</div>
) : (
<>
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center w-12 h-12 rounded-xl ${aiDiscrepancies > 0 ? 'bg-amber-100 dark:bg-amber-900/30' : 'bg-emerald-100 dark:bg-emerald-900/30'}`}>
{aiDiscrepancies > 0 ? (
<AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />
) : (
<ThumbsUp className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
)}
</div>
<div>
<p className="font-bold text-gray-900 dark:text-gray-100">
{aiDiscrepancies > 0 ? t('grading.discrepancies_found') : t('grading.no_discrepancies')}
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
{aiDiscrepancies > 0 ? `${aiDiscrepancies} ${t('grading_review.discrepancies_found').toLowerCase()}` : t('grading_review.no_discrepancies')}
</p>
</div>
</div>
<div className="p-4 rounded-xl bg-gradient-to-r from-gray-50 to-gray-50/0 dark:from-gray-800/50 dark:to-gray-800/0 border border-gray-200/40 dark:border-gray-800/40">
<p className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">{t('grading_review.ai_feedback')}</p>
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-line">{aiReviewResult}</p>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" className="rounded-xl" onClick={() => setAiReviewOpen(false)}>{t('action.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</motion.div>
);
}
+846
View File
@@ -0,0 +1,846 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Users, Heart, GraduationCap, Clock, CheckCircle2, XCircle,
AlertCircle, Loader2, ChevronRight, Calendar, BookOpen,
Shield, MessageSquare, Thermometer, TrendingUp, Timer,
Plus, FileText, BarChart3,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Progress } from '@/components/ui/progress';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiPost } from '@/lib/api';
import { toast } from 'sonner';
import { format, parseISO, differenceInCalendarDays } from 'date-fns';
import { de as deLocale, enUS as enLocale } from 'date-fns/locale';
/* ── Types ─────────────────────────────────────────────────────────── */
interface ChildData {
studentId: string;
firstName: string;
lastName: string;
relationship: string | null;
userId: string | null;
classGroups: Array<{ id: string; name: string; gradeLevel: number }>;
illnessReports: Array<{
id: string;
reason: string;
description: string | null;
startDate: string;
endDate: string | null;
parentApprovalStatus: string;
status: string;
reporterType: string;
createdAt: string;
}>;
upcomingExams: Array<{
id: string;
title: string;
date: string;
startTime: string | null;
endTime: string | null;
subject: { id: string; name: string } | null;
classGroup: { id: string; name: string } | null;
daysUntil: number;
}>;
recentGrades: Array<{
id: string;
computedValue: number;
period: string;
subject: { id: string; name: string };
classGroup: { id: string; name: string };
computedAt: string;
}>;
attendanceSummary: {
total: number;
present: number;
absent: number;
excused: number;
late: number;
};
counselingAppointments: Array<{
id: string;
requestType: string;
status: string;
scheduledAt: string | null;
description: string | null;
counselor: { firstName: string; lastName: string };
}>;
disciplinaryCases: Array<{
id: string;
caseType: string;
description: string;
status: string;
createdAt: string;
resolution: string | null;
}>;
}
interface PendingApproval {
id: string;
reason: string;
description: string | null;
startDate: string;
endDate: string | null;
parentApprovalStatus: string;
status: string;
reporterType: string;
student: { id: string; firstName: string; lastName: string };
}
/* ── Main Component ────────────────────────────────────────────────── */
export default function ParentPortalView() {
const currentUser = useAppStore((s) => s.currentUser);
const locale = useAppStore((s) => s.locale);
const localeCode = (locale === 'en' ? 'en' : 'de') as 'de' | 'en';
const [children, setChildren] = useState<ChildData[]>([]);
const [pendingApprovals, setPendingApprovals] = useState<PendingApproval[]>([]);
const [selectedChild, setSelectedChild] = useState<ChildData | null>(null);
const [loading, setLoading] = useState(true);
const [showIllnessDialog, setShowIllnessDialog] = useState(false);
const [showConversationDialog, setShowConversationDialog] = useState(false);
const [illnessForm, setIllnessForm] = useState({
reason: 'illness',
description: '',
startDate: format(new Date(), 'yyyy-MM-dd'),
endDate: '',
});
const [conversationForm, setConversationForm] = useState({
message: '',
});
const [submitting, setSubmitting] = useState(false);
const loadData = useCallback(async () => {
if (!currentUser?.schoolId) return;
setLoading(true);
try {
const res = await fetch(`/api/parent-portal?schoolId=${currentUser.schoolId}`);
if (res.ok) {
const data = await res.json();
setChildren(data.children ?? []);
setPendingApprovals(data.pendingApprovals ?? []);
if (data.children?.length > 0 && !selectedChild) {
setSelectedChild(data.children[0]);
}
}
} catch {
toast.error('Failed to load parent portal data');
} finally {
setLoading(false);
}
}, [currentUser?.schoolId]);
useEffect(() => {
loadData();
}, [loadData]);
const handleApproveIllness = async (reportId: string) => {
try {
const res = await fetch(`/api/illness-reports/approve?XTransformPort=3000`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reportId, action: 'approve' }),
});
if (res.ok) {
toast.success(t('parent_portal.illness_approved'));
loadData();
} else {
toast.error('Failed to approve illness report');
}
} catch {
toast.error('Failed to approve illness report');
}
};
const handleRejectIllness = async (reportId: string) => {
try {
const res = await fetch(`/api/illness-reports/approve?XTransformPort=3000`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reportId, action: 'reject' }),
});
if (res.ok) {
toast.success(t('parent_portal.illness_rejected'));
loadData();
} else {
toast.error('Failed to reject illness report');
}
} catch {
toast.error('Failed to reject illness report');
}
};
const handleReportIllness = async () => {
if (!currentUser?.schoolId || !selectedChild) return;
setSubmitting(true);
try {
await apiPost('/api/illness-reports', {
schoolId: currentUser.schoolId,
studentId: selectedChild.studentId,
reportedBy: currentUser.id,
reporterType: 'parent',
reason: illnessForm.reason,
description: illnessForm.description,
startDate: illnessForm.startDate,
endDate: illnessForm.endDate || null,
});
toast.success(t('parent_portal.report_illness'));
setShowIllnessDialog(false);
setIllnessForm({ reason: 'illness', description: '', startDate: format(new Date(), 'yyyy-MM-dd'), endDate: '' });
loadData();
} catch {
toast.error('Failed to report illness');
} finally {
setSubmitting(false);
}
};
const handleRequestConversation = async () => {
if (!currentUser?.schoolId || !selectedChild) return;
setSubmitting(true);
try {
await apiPost('/api/counseling', {
schoolId: currentUser.schoolId,
studentId: selectedChild.userId,
requestType: 'guidance',
description: conversationForm.message,
});
toast.success(t('parent_portal.conversation_requested'));
setShowConversationDialog(false);
setConversationForm({ message: '' });
loadData();
} catch {
toast.error('Failed to request conversation');
} finally {
setSubmitting(false);
}
};
const getAttendanceRate = (summary: { total: number; present: number; excused: number }) => {
if (summary.total === 0) return 100;
return Math.round(((summary.present + summary.excused) / summary.total) * 100);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
</div>
);
}
if (children.length === 0) {
return (
<div className="space-y-6 pb-6">
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shrink-0">
<Heart className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
{t('parent_portal.title')}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('parent_portal.subtitle')}
</p>
</div>
</div>
</motion.div>
<Card className="border-emerald-100/60 dark:border-emerald-900/30">
<CardContent className="p-8 text-center">
<Users className="h-12 w-12 text-emerald-300/60 dark:text-emerald-700/50 mx-auto mb-3" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">
{t('parent_portal.no_children')}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('parent_portal.no_children_desc')}
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6 pb-6">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-lg shadow-emerald-300/40 dark:shadow-emerald-900/40 shrink-0">
<Heart className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 dark:text-gray-100">
{t('parent_portal.title')}
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('parent_portal.subtitle')}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{selectedChild && (
<>
<Button
size="sm"
onClick={() => setShowIllnessDialog(true)}
className="bg-emerald-500 hover:bg-emerald-600 text-white min-h-[44px]"
>
<Thermometer className="h-4 w-4 mr-1.5" />
{t('parent_portal.report_illness')}
</Button>
<Button
size="sm"
onClick={() => setShowConversationDialog(true)}
variant="outline"
className="border-emerald-200/60 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 min-h-[44px]"
>
<MessageSquare className="h-4 w-4 mr-1.5" />
{t('parent_portal.request_conversation')}
</Button>
</>
)}
</div>
</motion.div>
{/* Pending Approvals */}
{pendingApprovals.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
>
<Card className="border-amber-200/60 dark:border-amber-900/30 bg-amber-50/40 dark:bg-amber-950/20">
<CardHeader className="pb-2 p-4">
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-amber-800 dark:text-amber-300">
<AlertCircle className="h-4 w-4" />
{t('parent_portal.pending_approvals')} ({pendingApprovals.length})
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="space-y-2 max-h-64 overflow-y-auto">
{pendingApprovals.map((approval) => (
<div key={approval.id} className="flex items-center justify-between p-2 rounded-lg bg-white dark:bg-gray-900/40 border border-amber-100 dark:border-amber-900/30">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{approval.student.firstName} {approval.student.lastName}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{approval.reason} - {format(parseISO(approval.startDate), 'd. MMM', { locale: localeCode === 'de' ? deLocale : enLocale })}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Button size="sm" onClick={() => handleApproveIllness(approval.id)} className="bg-emerald-500 hover:bg-emerald-600 text-white h-7 text-xs px-2">
<CheckCircle2 className="h-3 w-3 mr-1" />
{t('parent_portal.approve')}
</Button>
<Button size="sm" variant="outline" onClick={() => handleRejectIllness(approval.id)} className="border-red-200/60 dark:border-red-900/30 text-red-600 dark:text-red-400 h-7 text-xs px-2">
<XCircle className="h-3 w-3 mr-1" />
{t('parent_portal.reject')}
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Child Selector */}
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="flex items-center gap-2 overflow-x-auto pb-1"
>
{children.map((child) => (
<button
key={child.studentId}
onClick={() => setSelectedChild(child)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border transition-all whitespace-nowrap min-h-[44px] ${
selectedChild?.studentId === child.studentId
? 'bg-emerald-500 text-white border-emerald-500 shadow-md'
: 'bg-white dark:bg-gray-900/40 border-emerald-100 dark:border-emerald-900/30 text-gray-700 dark:text-gray-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20'
}`}
>
<Avatar className="h-6 w-6">
<AvatarFallback className={`text-xs ${selectedChild?.studentId === child.studentId ? 'bg-emerald-600 text-white' : 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'}`}>
{child.firstName[0]}{child.lastName[0]}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{child.firstName} {child.lastName}</span>
{child.classGroups.length > 0 && (
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${selectedChild?.studentId === child.studentId ? 'border-white/30 text-white' : 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 border-emerald-200/60 dark:border-emerald-900/30'}`}>
{child.classGroups[0].name}
</Badge>
)}
</button>
))}
</motion.div>
{/* Child Data */}
{selectedChild && (
<motion.div
key={selectedChild.studentId}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<Tabs defaultValue="overview" className="space-y-4">
<TabsList className="flex flex-wrap h-auto gap-1 bg-emerald-50/60 dark:bg-emerald-950/20 p-1 rounded-lg">
<TabsTrigger value="overview" className="data-[state=active]:bg-emerald-500 data-[state=active]:text-white text-xs">
{t('parent_portal.child_overview', { name: selectedChild.firstName })}
</TabsTrigger>
<TabsTrigger value="exams" className="data-[state=active]:bg-emerald-500 data-[state=active]:text-white text-xs">
<GraduationCap className="h-3 w-3 mr-1" />{t('parent_portal.child_exams')}
</TabsTrigger>
<TabsTrigger value="grades" className="data-[state=active]:bg-emerald-500 data-[state=active]:text-white text-xs">
<BarChart3 className="h-3 w-3 mr-1" />{t('parent_portal.child_grades')}
</TabsTrigger>
<TabsTrigger value="attendance" className="data-[state=active]:bg-emerald-500 data-[state=active]:text-white text-xs">
<Clock className="h-3 w-3 mr-1" />{t('parent_portal.child_attendance')}
</TabsTrigger>
<TabsTrigger value="illness" className="data-[state=active]:bg-emerald-500 data-[state=active]:text-white text-xs">
<Thermometer className="h-3 w-3 mr-1" />{t('parent_portal.illness_reports')}
</TabsTrigger>
<TabsTrigger value="counseling" className="data-[state=active]:bg-emerald-500 data-[state=active]:text-white text-xs">
<MessageSquare className="h-3 w-3 mr-1" />{t('parent_portal.child_counseling')}
</TabsTrigger>
<TabsTrigger value="disciplinary" className="data-[state=active]:bg-emerald-500 data-[state=active]:text-white text-xs">
<Shield className="h-3 w-3 mr-1" />{t('parent_portal.child_disciplinary')}
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-emerald-100/60 dark:border-emerald-900/30">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400">
<GraduationCap className="h-5 w-5" />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('parent_portal.upcoming_exams')}</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">{selectedChild.upcomingExams.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-emerald-100/60 dark:border-emerald-900/30">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400">
<TrendingUp className="h-5 w-5" />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('parent_portal.attendance_rate')}</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">{getAttendanceRate(selectedChild.attendanceSummary)}%</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-emerald-100/60 dark:border-emerald-900/30">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<BarChart3 className="h-5 w-5" />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('parent_portal.recent_grades')}</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">{selectedChild.recentGrades.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card className="border-emerald-100/60 dark:border-emerald-900/30">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<MessageSquare className="h-5 w-5" />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('parent_portal.counseling_appointments')}</p>
<p className="text-xl font-bold text-gray-900 dark:text-gray-100">{selectedChild.counselingAppointments.length}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Quick actions */}
<div className="mt-4 grid grid-cols-2 sm:grid-cols-4 gap-3">
<Button onClick={() => setShowIllnessDialog(true)} variant="outline" className="h-auto py-3 flex flex-col items-center gap-1.5 border-emerald-200/60 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 min-h-[44px]">
<Thermometer className="h-5 w-5" />
<span className="text-xs">{t('parent_portal.report_illness')}</span>
</Button>
<Button onClick={() => setShowConversationDialog(true)} variant="outline" className="h-auto py-3 flex flex-col items-center gap-1.5 border-emerald-200/60 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 min-h-[44px]">
<MessageSquare className="h-5 w-5" />
<span className="text-xs">{t('parent_portal.request_conversation')}</span>
</Button>
<Button variant="outline" className="h-auto py-3 flex flex-col items-center gap-1.5 border-emerald-200/60 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 min-h-[44px]">
<Calendar className="h-5 w-5" />
<span className="text-xs">{t('parent_portal.child_exams')}</span>
</Button>
<Button variant="outline" className="h-auto py-3 flex flex-col items-center gap-1.5 border-emerald-200/60 dark:border-emerald-900/30 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 min-h-[44px]">
<BarChart3 className="h-5 w-5" />
<span className="text-xs">{t('parent_portal.child_grades')}</span>
</Button>
</div>
</TabsContent>
{/* Exams Tab */}
<TabsContent value="exams">
<Card className="border-emerald-100/60 dark:border-emerald-900/30">
<CardHeader className="pb-2 p-4">
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
<GraduationCap className="h-4 w-4 text-red-500" />
{t('parent_portal.upcoming_exams')}
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
{selectedChild.upcomingExams.length === 0 ? (
<div className="text-center py-6">
<GraduationCap className="h-8 w-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('parent_portal.no_exams')}</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{selectedChild.upcomingExams.map((exam) => {
const countdownText = exam.daysUntil === 0
? t('calendar.today')
: exam.daysUntil === 1
? t('calendar.tomorrow')
: exam.daysUntil < 7
? t('calendar.in_days', { days: exam.daysUntil })
: t('calendar.in_weeks', { weeks: Math.floor(exam.daysUntil / 7) });
return (
<div key={exam.id} className="flex items-center justify-between p-3 rounded-lg bg-red-50/60 dark:bg-red-950/20 border border-red-100 dark:border-red-900/30">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400">
<GraduationCap className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{exam.title}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{exam.subject?.name ?? ''} {exam.classGroup?.name ?? ''} - {format(parseISO(exam.date), 'd. MMM yyyy', { locale: localeCode === 'de' ? deLocale : enLocale })}
</p>
</div>
</div>
<Badge variant="outline" className="text-[10px] bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-red-200/60 dark:border-red-900/30 shrink-0">
<Timer className="h-2.5 w-2.5 mr-0.5" />
{countdownText}
</Badge>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Grades Tab */}
<TabsContent value="grades">
<Card className="border-emerald-100/60 dark:border-emerald-900/30">
<CardHeader className="pb-2 p-4">
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
<BarChart3 className="h-4 w-4 text-amber-500" />
{t('parent_portal.recent_grades')}
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
{selectedChild.recentGrades.length === 0 ? (
<div className="text-center py-6">
<BarChart3 className="h-8 w-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('parent_portal.no_grades')}</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{selectedChild.recentGrades.map((grade) => (
<div key={grade.id} className="flex items-center justify-between p-3 rounded-lg bg-amber-50/60 dark:bg-amber-950/20 border border-amber-100 dark:border-amber-900/30">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400">
<span className="text-sm font-bold">{grade.computedValue.toFixed(1)}</span>
</div>
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{grade.subject.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{grade.period} - {grade.classGroup.name}</p>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Attendance Tab */}
<TabsContent value="attendance">
<Card className="border-emerald-100/60 dark:border-emerald-900/30">
<CardHeader className="pb-2 p-4">
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
<Clock className="h-4 w-4 text-emerald-500" />
{t('parent_portal.attendance_summary')}
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0 space-y-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className="text-sm text-gray-600 dark:text-gray-400">{t('parent_portal.attendance_rate')}</span>
<span className="text-sm font-bold text-emerald-600 dark:text-emerald-400">{getAttendanceRate(selectedChild.attendanceSummary)}%</span>
</div>
<Progress value={getAttendanceRate(selectedChild.attendanceSummary)} className="h-2" />
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 rounded-lg bg-emerald-50/60 dark:bg-emerald-950/20 border border-emerald-100 dark:border-emerald-900/30 text-center">
<p className="text-lg font-bold text-emerald-600 dark:text-emerald-400">{selectedChild.attendanceSummary.present}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('parent_portal.days_present')}</p>
</div>
<div className="p-3 rounded-lg bg-red-50/60 dark:bg-red-950/20 border border-red-100 dark:border-red-900/30 text-center">
<p className="text-lg font-bold text-red-600 dark:text-red-400">{selectedChild.attendanceSummary.absent}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('parent_portal.days_absent')}</p>
</div>
<div className="p-3 rounded-lg bg-amber-50/60 dark:bg-amber-950/20 border border-amber-100 dark:border-amber-900/30 text-center">
<p className="text-lg font-bold text-amber-600 dark:text-amber-400">{selectedChild.attendanceSummary.excused}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{localeCode === 'de' ? 'Entschuldigt' : 'Excused'}</p>
</div>
<div className="p-3 rounded-lg bg-sky-50/60 dark:bg-sky-950/20 border border-sky-100 dark:border-sky-900/30 text-center">
<p className="text-lg font-bold text-sky-600 dark:text-sky-400">{selectedChild.attendanceSummary.late}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{localeCode === 'de' ? 'Verspaetet' : 'Late'}</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Illness Reports Tab */}
<TabsContent value="illness">
<Card className="border-emerald-100/60 dark:border-emerald-900/30">
<CardHeader className="pb-2 p-4">
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
<Thermometer className="h-4 w-4 text-rose-500" />
{t('parent_portal.illness_reports')}
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
{selectedChild.illnessReports.length === 0 ? (
<div className="text-center py-6">
<Thermometer className="h-8 w-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('parent_portal.no_illness_reports')}</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{selectedChild.illnessReports.map((report) => (
<div key={report.id} className="flex items-center justify-between p-3 rounded-lg bg-rose-50/60 dark:bg-rose-950/20 border border-rose-100 dark:border-rose-900/30">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{report.reason}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{format(parseISO(report.startDate), 'd. MMM', { locale: localeCode === 'de' ? deLocale : enLocale })}
{report.endDate ? ` - ${format(parseISO(report.endDate), 'd. MMM', { locale: localeCode === 'de' ? deLocale : enLocale })}` : ''}
</p>
</div>
<Badge variant="outline" className={
report.parentApprovalStatus === 'approved'
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 border-emerald-200/60 dark:border-emerald-900/30'
: report.parentApprovalStatus === 'rejected'
? 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 border-red-200/60 dark:border-red-900/30'
: 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border-amber-200/60 dark:border-amber-900/30'
}>
{report.parentApprovalStatus === 'approved' ? t('parent_portal.approved') : report.parentApprovalStatus === 'rejected' ? t('parent_portal.rejected') : t('status.pending')}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Counseling Tab */}
<TabsContent value="counseling">
<Card className="border-emerald-100/60 dark:border-emerald-900/30">
<CardHeader className="pb-2 p-4">
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
<MessageSquare className="h-4 w-4 text-violet-500" />
{t('parent_portal.counseling_appointments')}
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
{selectedChild.counselingAppointments.length === 0 ? (
<div className="text-center py-6">
<MessageSquare className="h-8 w-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('parent_portal.no_counseling')}</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{selectedChild.counselingAppointments.map((apt) => (
<div key={apt.id} className="flex items-center justify-between p-3 rounded-lg bg-violet-50/60 dark:bg-violet-950/20 border border-violet-100 dark:border-violet-900/30">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{apt.requestType}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{apt.counselor.firstName} {apt.counselor.lastName}
{apt.scheduledAt ? ` - ${format(parseISO(apt.scheduledAt), 'd. MMM yyyy', { locale: localeCode === 'de' ? deLocale : enLocale })}` : ''}
</p>
</div>
<Badge variant="outline" className="bg-violet-50 dark:bg-violet-900/20 text-violet-700 dark:text-violet-300 border-violet-200/60 dark:border-violet-900/30">
{apt.status}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Disciplinary Tab */}
<TabsContent value="disciplinary">
<Card className="border-emerald-100/60 dark:border-emerald-900/30">
<CardHeader className="pb-2 p-4">
<CardTitle className="flex items-center gap-2 text-sm font-semibold text-gray-900 dark:text-gray-100">
<Shield className="h-4 w-4 text-orange-500" />
{t('parent_portal.disciplinary_cases')}
</CardTitle>
</CardHeader>
<CardContent className="p-4 pt-0">
{selectedChild.disciplinaryCases.length === 0 ? (
<div className="text-center py-6">
<Shield className="h-8 w-8 text-gray-300 dark:text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('parent_portal.no_disciplinary')}</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{selectedChild.disciplinaryCases.map((dCase) => (
<div key={dCase.id} className="flex items-center justify-between p-3 rounded-lg bg-orange-50/60 dark:bg-orange-950/20 border border-orange-100 dark:border-orange-900/30">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">{dCase.caseType}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{format(parseISO(dCase.createdAt), 'd. MMM yyyy', { locale: localeCode === 'de' ? deLocale : enLocale })}
</p>
</div>
<Badge variant="outline" className={
dCase.status === 'resolved'
? 'bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300 border-emerald-200/60 dark:border-emerald-900/30'
: 'bg-orange-50 dark:bg-orange-900/20 text-orange-700 dark:text-orange-300 border-orange-200/60 dark:border-orange-900/30'
}>
{dCase.status}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
)}
{/* Illness Report Dialog */}
<Dialog open={showIllnessDialog} onOpenChange={setShowIllnessDialog}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Thermometer className="h-5 w-5 text-emerald-500" />
{t('parent_portal.report_illness')}
</DialogTitle>
<DialogDescription>
{selectedChild ? `${t('parent_portal.report_illness')} - ${selectedChild.firstName} ${selectedChild.lastName}` : ''}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>{t('illness.reason')}</Label>
<select
value={illnessForm.reason}
onChange={(e) => setIllnessForm((d) => ({ ...d, reason: e.target.value }))}
className="w-full h-10 rounded-md border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 px-3 text-sm"
>
<option value="illness">{localeCode === 'de' ? 'Krankheit' : 'Illness'}</option>
<option value="doctor_visit">{localeCode === 'de' ? 'Arztbesuch' : 'Doctor Visit'}</option>
<option value="other">{localeCode === 'de' ? 'Sonstiges' : 'Other'}</option>
</select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-2">
<Label>{localeCode === 'de' ? 'Von' : 'From'}</Label>
<Input type="date" value={illnessForm.startDate} onChange={(e) => setIllnessForm((d) => ({ ...d, startDate: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>{localeCode === 'de' ? 'Bis' : 'Until'}</Label>
<Input type="date" value={illnessForm.endDate} onChange={(e) => setIllnessForm((d) => ({ ...d, endDate: e.target.value }))} />
</div>
</div>
<div className="space-y-2">
<Label>{t('label.description')}</Label>
<Textarea value={illnessForm.description} onChange={(e) => setIllnessForm((d) => ({ ...d, description: e.target.value }))} rows={2} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowIllnessDialog(false)} className="min-h-[44px]">{t('action.cancel')}</Button>
<Button onClick={handleReportIllness} disabled={submitting} className="bg-emerald-500 hover:bg-emerald-600 text-white min-h-[44px]">
{submitting ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Thermometer className="h-4 w-4 mr-1.5" />}
{t('parent_portal.report_illness')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Conversation Request Dialog */}
<Dialog open={showConversationDialog} onOpenChange={setShowConversationDialog}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-emerald-500" />
{t('parent_portal.request_conversation')}
</DialogTitle>
<DialogDescription>
{selectedChild ? `${t('parent_portal.request_conversation')} - ${selectedChild.firstName} ${selectedChild.lastName}` : ''}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>{t('label.description')}</Label>
<Textarea value={conversationForm.message} onChange={(e) => setConversationForm((d) => ({ ...d, message: e.target.value }))} rows={3} placeholder={localeCode === 'de' ? 'Beschreiben Sie Ihr Anliegen...' : 'Describe your concern...'} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowConversationDialog(false)} className="min-h-[44px]">{t('action.cancel')}</Button>
<Button onClick={handleRequestConversation} disabled={submitting || !conversationForm.message.trim()} className="bg-emerald-500 hover:bg-emerald-600 text-white min-h-[44px]">
{submitting ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <MessageSquare className="h-4 w-4 mr-1.5" />}
{t('parent_portal.request_conversation')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+202
View File
@@ -67,6 +67,7 @@ import {
Target,
CalendarCheck,
Flame,
MessageSquare,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -2471,6 +2472,14 @@ export default function SettingsView() {
<Shield className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">{t('dsgvo.tab_privacy')}</span>
</TabsTrigger>
<TabsTrigger value="ai" className="rounded-lg min-h-[44px] data-[state=active]:bg-violet-500 data-[state=active]:text-white">
<Zap className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">{t('settings.tab_ai')}</span>
</TabsTrigger>
<TabsTrigger value="management" className="rounded-lg min-h-[44px] data-[state=active]:bg-teal-500 data-[state=active]:text-white">
<Users className="h-4 w-4 mr-1.5" />
<span className="hidden sm:inline">{t('settings.tab_management')}</span>
</TabsTrigger>
</TabsList>
{/* ── School Info Tab ─────────────────────────────────── */}
@@ -5183,6 +5192,199 @@ export default function SettingsView() {
<TabsContent value="privacy">
<PrivacyTab currentUser={currentUser} />
</TabsContent>
{/* ── AI Settings Tab ─────────────────────────────────────────── */}
<TabsContent value="ai">
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
<Zap className="h-4 w-4" />
</div>
{t('settings.ai_settings')}
</CardTitle>
<CardDescription>{t('settings.ai_settings_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* AI Provider Selection */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('settings.ai_provider')}</Label>
<Select defaultValue="pollination">
<SelectTrigger className="rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pollination">Pollination AI</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
</SelectContent>
</Select>
</div>
{/* API Key */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('settings.ai_api_key')}</Label>
<Input type="password" placeholder="sk-..." className="rounded-xl" />
<p className="text-xs text-gray-500 dark:text-gray-400">{locale === 'de' ? 'Ihr API-Schluessel wird verschluesselt gespeichert.' : 'Your API key is stored encrypted.'}</p>
</div>
{/* Model Selection */}
<div className="space-y-2">
<Label className="text-sm font-medium">{t('settings.ai_model')}</Label>
<Select defaultValue="gpt-4o-mini">
<SelectTrigger className="rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="gpt-4o-mini">GPT-4o Mini</SelectItem>
<SelectItem value="gpt-4o">GPT-4o</SelectItem>
<SelectItem value="claude-3-haiku">Claude 3 Haiku</SelectItem>
<SelectItem value="claude-3-sonnet">Claude 3 Sonnet</SelectItem>
</SelectContent>
</Select>
</div>
{/* Test Connection */}
<Button className="rounded-xl bg-gradient-to-r from-violet-500 to-violet-600 text-white shadow-md" onClick={() => toast.success(t('settings.ai_connection_ok'))}>
<Zap className="h-4 w-4 mr-1" />
{t('settings.ai_test_connection')}
</Button>
{/* Virtual Character Toggle */}
<div className="mt-6 p-4 rounded-xl bg-gradient-to-r from-teal-50/60 to-teal-50/0 dark:from-teal-900/15 dark:to-teal-900/0 border border-teal-100/60 dark:border-teal-900/30">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm">
<MessageSquare className="h-5 w-5" />
</div>
<div>
<p className="text-sm font-bold text-gray-900 dark:text-gray-100">{t('settings.virtual_character')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('settings.virtual_character_desc')}</p>
</div>
</div>
<Switch defaultChecked />
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* ── Management Tab ─────────────────────────────────────────── */}
<TabsContent value="management">
<div className="space-y-6">
{/* Responsible Teacher */}
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-emerald-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-emerald-400 to-emerald-500 text-white shadow-sm">
<GraduationCap className="h-4 w-4" />
</div>
{t('settings.responsible_teacher')}
</CardTitle>
<CardDescription>{t('settings.responsible_teacher_desc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{classes.slice(0, 5).map((cls) => (
<div key={cls.id} className="flex items-center justify-between p-3 rounded-xl bg-gradient-to-r from-emerald-50/40 to-transparent dark:from-emerald-900/10 dark:to-transparent border border-emerald-100/40 dark:border-emerald-900/20">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400 text-xs font-bold">
{cls.name.substring(0, 2).toUpperCase()}
</div>
<div>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{cls.name}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">{t('label.grade')} {cls.gradeLevel}</p>
</div>
</div>
<Select defaultValue="">
<SelectTrigger className="w-48 rounded-xl h-8 text-xs">
<SelectValue placeholder={locale === 'de' ? 'Lehrer zuweisen' : 'Assign teacher'} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{locale === 'de' ? 'Automatisch' : 'Automatic'}</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
</CardContent>
</Card>
{/* Vice Principal Management */}
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-teal-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-teal-400 to-teal-500 text-white shadow-sm">
<Shield className="h-4 w-4" />
</div>
{t('settings.vice_principal')}
</CardTitle>
<CardDescription>{t('settings.vice_principal_desc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-6">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-teal-100 dark:bg-teal-900/30 mx-auto mb-3">
<Shield className="h-6 w-6 text-teal-600 dark:text-teal-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">{locale === 'de' ? 'Keine Stellvertretenden Schulleiter zugewiesen' : 'No vice principals assigned'}</p>
<Button className="rounded-xl bg-gradient-to-r from-teal-500 to-teal-600 text-white shadow-md" onClick={() => toast.info(locale === 'de' ? 'Funktion wird bald verfuegbar sein' : 'Feature coming soon')}>
<UserPlus className="h-4 w-4 mr-1" />
{t('action.add')}
</Button>
</div>
</CardContent>
</Card>
{/* Disciplinary Committee */}
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-amber-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-amber-50/50 to-transparent dark:from-amber-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm">
<AlertTriangle className="h-4 w-4" />
</div>
{t('settings.disciplinary_committee')}
</CardTitle>
<CardDescription>{t('settings.disciplinary_committee_desc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center py-6">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-amber-100 dark:bg-amber-900/30 mx-auto mb-3">
<AlertTriangle className="h-6 w-6 text-amber-600 dark:text-amber-400" />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">{locale === 'de' ? 'Kein Ausschuss konfiguriert' : 'No committee configured'}</p>
<Button className="rounded-xl bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-md" onClick={() => toast.info(locale === 'de' ? 'Funktion wird bald verfuegbar sein' : 'Feature coming soon')}>
<UserPlus className="h-4 w-4 mr-1" />
{t('action.add')}
</Button>
</div>
</CardContent>
</Card>
{/* Data Import */}
<Card className="card-hover-lift border-0 shadow-sm rounded-xl border-l-3 border-l-violet-500 overflow-hidden">
<CardHeader className="bg-gradient-to-r from-violet-50/50 to-transparent dark:from-violet-900/10 dark:to-transparent">
<CardTitle className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-gradient-to-br from-violet-400 to-violet-500 text-white shadow-sm">
<Upload className="h-4 w-4" />
</div>
{t('settings.data_import')}
</CardTitle>
<CardDescription>{t('settings.data_import_desc')}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center gap-4 p-4 rounded-xl border-2 border-dashed border-violet-200 dark:border-violet-800 bg-violet-50/30 dark:bg-violet-900/10">
<Upload className="h-8 w-8 text-violet-400 dark:text-violet-500" />
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">{t('settings.data_import_file')}</p>
<p className="text-xs text-gray-500 dark:text-gray-400">CSV, JSON</p>
</div>
<Button className="rounded-xl bg-gradient-to-r from-violet-500 to-violet-600 text-white shadow-md" onClick={() => toast.info(t('settings.data_import_started'))}>
{t('settings.data_import_file')}
</Button>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</motion.div>
</motion.div>
+93
View File
@@ -216,6 +216,12 @@ export default function SubjectsView() {
const [contentForm, setContentForm] = useState({ title: '', slug: '', description: '', icon: 'BookOpen', contentType: 'topic', difficulty: 'medium', isActive: true, isPublic: true });
const [changeRequestForm, setChangeRequestForm] = useState({ requestType: 'edit' as const, title: '', description: '', proposedChanges: '' });
// Crawl state
const [showCrawlDialog, setShowCrawlDialog] = useState(false);
const [crawlUrl, setCrawlUrl] = useState('');
const [isCrawling, setIsCrawling] = useState(false);
const [crawlResult, setCrawlResult] = useState<{ topicsFound: number; contentsCreated: number } | null>(null);
/* ── Data loading ───────────────────────────────────────────────── */
const loadCategories = useCallback(async () => {
@@ -585,6 +591,12 @@ export default function SubjectsView() {
{t('subjects.seed_data')}
</Button>
)}
{isAdmin && (
<Button onClick={() => setShowCrawlDialog(true)} variant="outline" size="sm" className="gap-1 bg-white/20 border-white/30 text-white hover:bg-white/30 hover:text-white">
<Globe className="h-4 w-4" />
{t('subjects.crawl')}
</Button>
)}
</div>
</div>
</motion.div>
@@ -2181,6 +2193,87 @@ export default function SubjectsView() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Crawl Dialog */}
<Dialog open={showCrawlDialog} onOpenChange={setShowCrawlDialog}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-emerald-500" />
{t('subjects.crawl')}
</DialogTitle>
<DialogDescription>{t('subjects.crawl_desc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label>{t('subjects.crawl_url')}</Label>
<Input
value={crawlUrl}
onChange={(e) => setCrawlUrl(e.target.value)}
placeholder={t('subjects.crawl_url_placeholder')}
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
{t('subjects.crawl_admin_only')}
</p>
</div>
{isCrawling && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800">
<Loader2 className="h-4 w-4 animate-spin text-emerald-500" />
<span className="text-sm text-emerald-700 dark:text-emerald-300">{t('subjects.crawling')}</span>
</div>
)}
{crawlResult && (
<div className="p-3 rounded-lg bg-emerald-50 dark:bg-emerald-950/30 border border-emerald-200 dark:border-emerald-800 space-y-1">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
<span className="text-sm font-medium text-emerald-700 dark:text-emerald-300">{t('subjects.crawl_complete')}</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="text-gray-600 dark:text-gray-400">{t('subjects.crawl_topics_found')}: <span className="font-semibold text-gray-900 dark:text-gray-100">{crawlResult.topicsFound}</span></div>
<div className="text-gray-600 dark:text-gray-400">{t('subjects.crawl_contents_created')}: <span className="font-semibold text-gray-900 dark:text-gray-100">{crawlResult.contentsCreated}</span></div>
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setShowCrawlDialog(false); setCrawlResult(null); }}>
{t('action.close')}
</Button>
<Button
onClick={async () => {
if (!crawlUrl || !schoolId) return;
setIsCrawling(true);
setCrawlResult(null);
try {
const res = await apiPost<{
success: boolean;
topicsFound: number;
contentsCreated: number;
}>('/api/subject-contents/crawl', {
url: crawlUrl,
schoolId,
categoryId: selectedCategory?.id,
});
if (res.success) {
setCrawlResult({ topicsFound: res.topicsFound, contentsCreated: res.contentsCreated });
toast.success(t('subjects.crawl_success'));
loadCategories();
}
} catch {
toast.error(t('subjects.crawl_error'));
} finally {
setIsCrawling(false);
}
}}
disabled={isCrawling || !crawlUrl.trim()}
className="bg-emerald-600 hover:bg-emerald-700 text-white"
>
{isCrawling ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Globe className="h-4 w-4 mr-2" />}
{t('subjects.crawl')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}