Files
schulos/src/components/report-card-view.tsx
T
Mustafa Cetin 3d0d518bb9 Round 25: Substitute Teacher, School Newsletter, Career Guidance, Styling
- Substitute Teacher Management View: pool, absences, assignments, statistics, weekly schedule
- School Newsletter View: editor, templates, distribution, analytics, open/click tracking
- Student Career & Guidance View: career exploration, interest quiz, portfolio, goals, appointments
- 6 new Prisma models: SubstituteTeacher, TeacherAbsence, SubstitutionAssignment, CareerProfile, CareerGoal, CareerAppointment
- Enhanced Newsletter model with content, scheduling, and analytics fields
- Enhanced settings-view with gradient header, glassmorphism cards, danger zone
- Enhanced student-portal-view with animated welcome banner, quick action cards
- 16+ new API route files
- 300+ i18n keys added
- All views verified working in browser
2026-07-30 06:36:34 +00:00

1878 lines
85 KiB
TypeScript
Executable File

'use client';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
FileText,
Plus,
Search,
Filter,
Download,
Eye,
Edit3,
Trash2,
Send,
Archive,
CheckCircle2,
Clock,
AlertCircle,
ChevronDown,
ChevronRight,
Printer,
Star,
BookOpen,
Users,
Calendar,
X,
Save,
Copy,
BarChart3,
Award,
MessageSquare,
UserCheck,
Shield,
GraduationCap,
Loader2,
MoreHorizontal,
LayoutTemplate,
} 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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Progress } from '@/components/ui/progress';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { apiGet, apiPost, apiPut, apiDelete } from '@/lib/api';
import { toast } from 'sonner';
import {
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
Radar,
ResponsiveContainer,
Tooltip as RechartsTooltip,
} from 'recharts';
// ─── Types ──────────────────────────────────────────────────────────
interface StudentInfo {
id: string;
firstName: string;
lastName: string;
avatarUrl?: string;
dateOfBirth?: string;
}
interface ClassGroupInfo {
id: string;
name: string;
gradeLevel: number;
schoolType?: string;
responsibleTeacher?: { id: string; firstName: string; lastName: string };
}
interface SchoolYearInfo {
id: string;
label: string;
}
interface UserInfo {
id: string;
firstName: string;
lastName: string;
}
interface CompetencyCategoryInfo {
id: string;
name: string;
color?: string;
}
interface ReportSectionInfo {
id: string;
competencyCategoryId?: string | null;
generatedText: string;
order: number;
competencyCategory?: CompetencyCategoryInfo | null;
}
interface ComputedGradeInfo {
id: string;
computedValue: number;
overriddenValue?: number | null;
isFinalized: boolean;
subject: { id: string; name: string };
}
interface TemplateInfo {
id: string;
name: string;
layout: string;
gradingScale?: string;
}
interface ReportCard {
id: string;
studentId: string;
classGroupId: string;
schoolYearId: string;
period: string;
generatedByUserId: string;
generatedAt: string;
status: string;
pdfFilePath?: string | null;
includesGrades: boolean;
teacherComments?: string | null;
attendanceSummary?: string | null;
overallAssessment?: string | null;
templateId?: string | null;
reviewedByUserId?: string | null;
reviewedAt?: string | null;
publishedAt?: string | null;
student: StudentInfo;
classGroup: ClassGroupInfo;
schoolYear: SchoolYearInfo;
generatedByUser: UserInfo;
sections: ReportSectionInfo[];
template?: TemplateInfo | null;
computedGrades?: ComputedGradeInfo[];
}
interface TemplateItem {
id: string;
name: string;
description?: string;
sections: string;
gradingScale: string;
layout: string;
isDefault: boolean;
}
// ─── German Grade Helpers ───────────────────────────────────────────
const GRADE_LABELS: Record<number, string> = {
1: 'Sehr gut',
2: 'Gut',
3: 'Befriedigend',
4: 'Ausreichend',
5: 'Mangelhaft',
6: 'Ungenügend',
};
function getGradeColor(grade: number): string {
if (grade <= 1) return 'text-emerald-600 bg-emerald-50 dark:bg-emerald-950/50 dark:text-emerald-400';
if (grade <= 2) return 'text-green-600 bg-green-50 dark:bg-green-950/50 dark:text-green-400';
if (grade <= 3) return 'text-yellow-600 bg-yellow-50 dark:bg-yellow-950/50 dark:text-yellow-400';
if (grade <= 4) return 'text-amber-600 bg-amber-50 dark:bg-amber-950/50 dark:text-amber-400';
if (grade <= 5) return 'text-orange-600 bg-orange-50 dark:bg-orange-950/50 dark:text-orange-400';
return 'text-red-600 bg-red-50 dark:bg-red-950/50 dark:text-red-400';
}
function getGradeBorder(grade: number): string {
if (grade <= 2) return 'border-emerald-300 dark:border-emerald-700';
if (grade <= 3) return 'border-yellow-300 dark:border-yellow-700';
if (grade <= 4) return 'border-amber-300 dark:border-amber-700';
return 'border-red-300 dark:border-red-700';
}
function getStatusBadge(status: string) {
const shimmerBase = 'relative overflow-hidden';
const shimmerAfter = 'after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/20 after:to-transparent after:animate-[shimmer_2s_infinite]';
switch (status) {
case 'DRAFT':
return <Badge variant="outline" className={`bg-gray-50 text-gray-600 dark:bg-gray-900/50 dark:text-gray-400 border-gray-200 dark:border-gray-700 ${shimmerBase} ${shimmerAfter}`}><Clock className="h-3 w-3 mr-1" />{t('report_card.status_draft')}</Badge>;
case 'REVIEW':
return <Badge variant="outline" className={`bg-amber-50 text-amber-600 dark:bg-amber-950/50 dark:text-amber-400 border-amber-200 dark:border-amber-700 ${shimmerBase} ${shimmerAfter}`}><AlertCircle className="h-3 w-3 mr-1" />{t('report_card.status_review')}</Badge>;
case 'PUBLISHED':
return <Badge variant="outline" className={`bg-emerald-50 text-emerald-600 dark:bg-emerald-950/50 dark:text-emerald-400 border-emerald-200 dark:border-emerald-700 ${shimmerBase} ${shimmerAfter}`}><CheckCircle2 className="h-3 w-3 mr-1" />{t('report_card.status_published')}</Badge>;
case 'ARCHIVED':
return <Badge variant="outline" className={`bg-slate-50 text-slate-600 dark:bg-slate-950/50 dark:text-slate-400 border-slate-200 dark:border-slate-700 ${shimmerBase} ${shimmerAfter}`}><Archive className="h-3 w-3 mr-1" />{t('report_card.status_archived')}</Badge>;
case 'FINAL':
return <Badge variant="outline" className={`bg-teal-50 text-teal-600 dark:bg-teal-950/50 dark:text-teal-400 border-teal-200 dark:border-teal-700 ${shimmerBase} ${shimmerAfter}`}><Star className="h-3 w-3 mr-1" />{t('report_card.status_final')}</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
}
// ─── Animated Counter ───────────────────────────────────────────────
function AnimatedCounter({ value, duration = 1000 }: { value: number; duration?: number }) {
const [count, setCount] = useState(0);
useEffect(() => {
let start = 0;
const step = Math.max(1, Math.ceil(value / (duration / 30)));
const timer = setInterval(() => {
start += step;
if (start >= value) {
setCount(value);
clearInterval(timer);
} else {
setCount(start);
}
}, 30);
return () => clearInterval(timer);
}, [value, duration]);
return <span>{count}</span>;
}
// ─── Main Component ─────────────────────────────────────────────────
export default function ReportCardView() {
const { currentUser, currentClassId, schoolYearId } = useAppStore();
const role = currentUser?.role ?? 'TEACHER';
const [reportCards, setReportCards] = useState<ReportCard[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [filterClass, setFilterClass] = useState<string>(currentClassId ?? 'all');
const [filterStatus, setFilterStatus] = useState<string>('all');
const [filterPeriod, setFilterPeriod] = useState<string>('all');
const [selectedReport, setSelectedReport] = useState<ReportCard | null>(null);
const [viewMode, setViewMode] = useState<'list' | 'editor' | 'preview'>('list');
const [classes, setClasses] = useState<{ id: string; name: string; gradeLevel: number }[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [templates, setTemplates] = useState<TemplateItem[]>([]);
// Dialog states
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showBatchDialog, setShowBatchDialog] = useState(false);
const [showTemplateDialog, setShowTemplateDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
// Editor state
const [editTeacherComments, setEditTeacherComments] = useState('');
const [editOverallAssessment, setEditOverallAssessment] = useState('');
const [editSections, setEditSections] = useState<ReportSectionInfo[]>([]);
const [isSaving, setIsSaving] = useState(false);
// Create form state
const [createForm, setCreateForm] = useState({
studentId: '',
classGroupId: currentClassId ?? '',
schoolYearId: schoolYearId ?? '',
period: 'Semester 1',
includesGrades: true,
templateId: '',
teacherComments: '',
overallAssessment: '',
});
const [students, setStudents] = useState<{ id: string; firstName: string; lastName: string }[]>([]);
const [schoolYears, setSchoolYears] = useState<{ id: string; label: string }[]>([]);
// Batch form state
const [batchForm, setBatchForm] = useState({
classGroupId: currentClassId ?? '',
schoolYearId: schoolYearId ?? '',
period: 'Semester 1',
templateId: '',
includesGrades: true,
});
// Template form state
const [templateForm, setTemplateForm] = useState({
name: '',
description: '',
layout: 'default' as 'default' | 'detailed' | 'compact',
isDefault: false,
});
// ─── Data Fetching ──────────────────────────────────────────────
const fetchReportCards = useCallback(async () => {
setIsLoading(true);
try {
const params = new URLSearchParams();
if (filterClass && filterClass !== 'all') params.set('classGroupId', filterClass);
if (filterStatus && filterStatus !== 'all') params.set('status', filterStatus);
if (filterPeriod && filterPeriod !== 'all') params.set('period', filterPeriod);
if (schoolYearId) params.set('schoolYearId', schoolYearId);
const data = await apiGet<ReportCard[]>(`/api/report-cards?${params.toString()}`);
setReportCards(data);
} catch (error) {
console.error('Failed to fetch report cards:', error);
toast.error(t('report_card.load_error'));
} finally {
setIsLoading(false);
}
}, [filterClass, filterStatus, filterPeriod, schoolYearId]);
const fetchClasses = useCallback(async () => {
try {
const data = await apiGet<{ id: string; name: string; gradeLevel: number }[]>('/api/classes');
setClasses(data);
} catch {
// ignore
}
}, []);
const fetchStudents = useCallback(async (classGroupId: string) => {
try {
const data = await apiGet<{ id: string; firstName: string; lastName: string }[]>(
`/api/classes/${classGroupId}/students`
);
setStudents(data);
} catch {
setStudents([]);
}
}, []);
const fetchSchoolYears = useCallback(async () => {
try {
const data = await apiGet<{ id: string; label: string }[]>('/api/school-years');
setSchoolYears(data);
if (!schoolYearId && data.length > 0) {
setCreateForm((f) => ({ ...f, schoolYearId: data[0].id }));
setBatchForm((f) => ({ ...f, schoolYearId: data[0].id }));
}
} catch {
// ignore
}
}, [schoolYearId]);
const fetchTemplates = useCallback(async () => {
if (!currentUser?.schoolId) return;
try {
const data = await apiGet<TemplateItem[]>(`/api/report-cards/templates?schoolId=${currentUser.schoolId}`);
setTemplates(data);
} catch {
// ignore
}
}, [currentUser?.schoolId]);
useEffect(() => {
fetchReportCards();
fetchClasses();
fetchSchoolYears();
fetchTemplates();
}, [fetchReportCards, fetchClasses, fetchSchoolYears, fetchTemplates]);
useEffect(() => {
if (createForm.classGroupId) {
fetchStudents(createForm.classGroupId);
}
}, [createForm.classGroupId, fetchStudents]);
// ─── Filtered Data ──────────────────────────────────────────────
const filteredCards = useMemo(() => {
let result = reportCards;
if (searchQuery) {
const q = searchQuery.toLowerCase();
result = result.filter(
(r) =>
r.student.firstName.toLowerCase().includes(q) ||
r.student.lastName.toLowerCase().includes(q) ||
r.classGroup.name.toLowerCase().includes(q) ||
r.period.toLowerCase().includes(q)
);
}
return result;
}, [reportCards, searchQuery]);
// ─── Stats ──────────────────────────────────────────────────────
const stats = useMemo(() => {
const total = reportCards.length;
const drafts = reportCards.filter((r) => r.status === 'DRAFT').length;
const inReview = reportCards.filter((r) => r.status === 'REVIEW').length;
const published = reportCards.filter((r) => r.status === 'PUBLISHED' || r.status === 'FINAL').length;
return { total, drafts, inReview, published };
}, [reportCards]);
// ─── Actions ────────────────────────────────────────────────────
const handleCreateReport = async () => {
try {
const report = await apiPost<ReportCard>('/api/report-cards', createForm);
setReportCards((prev) => [report, ...prev]);
setShowCreateDialog(false);
toast.success(t('report_card.created'));
} catch (error) {
toast.error(t('report_card.create_error'));
}
};
const handleBatchGenerate = async () => {
try {
const result = await apiPost<{ generated: number; skipped: number }>('/api/report-cards/generate', batchForm);
toast.success(t('report_card.batch_generated').replace('{count}', String(result.generated)));
setShowBatchDialog(false);
fetchReportCards();
} catch (error) {
toast.error(t('report_card.batch_error'));
}
};
const handleSaveReport = async () => {
if (!selectedReport) return;
setIsSaving(true);
try {
const updated = await apiPut<ReportCard>(`/api/report-cards/${selectedReport.id}`, {
teacherComments: editTeacherComments,
overallAssessment: editOverallAssessment,
sections: editSections.map((s, i) => ({
competencyCategoryId: s.competencyCategoryId,
generatedText: s.generatedText,
order: i,
})),
});
setReportCards((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
setSelectedReport(updated);
toast.success(t('report_card.saved'));
} catch (error) {
toast.error(t('report_card.save_error'));
} finally {
setIsSaving(false);
}
};
const handleStatusChange = async (reportId: string, newStatus: string) => {
try {
const updated = await apiPut<ReportCard>(`/api/report-cards/${reportId}`, { status: newStatus });
setReportCards((prev) => prev.map((r) => (r.id === updated.id ? updated : r)));
if (selectedReport?.id === reportId) setSelectedReport(updated);
toast.success(t('report_card.status_updated'));
} catch (error) {
toast.error(t('report_card.status_error'));
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await apiDelete(`/api/report-cards/${deleteTarget}`);
setReportCards((prev) => prev.filter((r) => r.id !== deleteTarget));
if (selectedReport?.id === deleteTarget) {
setSelectedReport(null);
setViewMode('list');
}
setShowDeleteDialog(false);
setDeleteTarget(null);
toast.success(t('report_card.deleted'));
} catch (error) {
toast.error(t('report_card.delete_error'));
}
};
const handleBulkAction = async (action: 'publish' | 'archive' | 'delete') => {
if (selectedIds.size === 0) return;
try {
for (const id of selectedIds) {
if (action === 'publish') {
await apiPut(`/api/report-cards/${id}`, { status: 'PUBLISHED' });
} else if (action === 'archive') {
await apiPut(`/api/report-cards/${id}`, { status: 'ARCHIVED' });
} else if (action === 'delete') {
await apiDelete(`/api/report-cards/${id}`);
}
}
setSelectedIds(new Set());
toast.success(t('report_card.bulk_action_success'));
fetchReportCards();
} catch (error) {
toast.error(t('report_card.bulk_action_error'));
}
};
const handleExportPDF = async (reportIds: string[]) => {
try {
const data = await apiPost<{
reports: ReportCard[];
school: { name: string; motto?: string; logoUrl?: string; address?: string; phone?: string; primaryColor?: string };
exportDate: string;
}>('/api/report-cards/export', { reportIds });
// Store export data and open print dialog
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(generatePrintHTML(data.reports, data.school));
printWindow.document.close();
setTimeout(() => printWindow.print(), 500);
}
toast.success(t('report_card.export_success'));
} catch (error) {
toast.error(t('report_card.export_error'));
}
};
const handlePrintCurrent = () => {
window.print();
};
const handleCreateTemplate = async () => {
try {
const template = await apiPost<TemplateItem>('/api/report-cards/templates', {
...templateForm,
sections: JSON.stringify([
{ type: 'header', label: t('report_card.section_header') },
{ type: 'grades', label: t('report_card.section_grades') },
{ type: 'competencies', label: t('report_card.section_competencies') },
{ type: 'comments', label: t('report_card.section_comments') },
{ type: 'attendance', label: t('report_card.section_attendance') },
{ type: 'signature', label: t('report_card.section_signature') },
]),
gradingScale: JSON.stringify({
min: 1, max: 6,
labels: { 1: 'Sehr gut', 2: 'Gut', 3: 'Befriedigend', 4: 'Ausreichend', 5: 'Mangelhaft', 6: 'Ungenügend' },
}),
});
setTemplates((prev) => [...prev, template]);
setShowTemplateDialog(false);
toast.success(t('report_card.template_created'));
} catch (error) {
toast.error(t('report_card.template_error'));
}
};
// ─── Editor Setup ───────────────────────────────────────────────
const openEditor = (report: ReportCard) => {
setSelectedReport(report);
setEditTeacherComments(report.teacherComments ?? '');
setEditOverallAssessment(report.overallAssessment ?? '');
setEditSections(report.sections ?? []);
setViewMode('editor');
};
const openPreview = (report: ReportCard) => {
setSelectedReport(report);
setViewMode('preview');
};
// ─── Radar Chart Data ──────────────────────────────────────────
const radarData = useMemo(() => {
if (!selectedReport?.computedGrades) return [];
const grades = selectedReport.computedGrades;
return grades.map((g) => ({
subject: g.subject.name,
grade: 7 - (g.overriddenValue ?? g.computedValue), // Invert for radar (higher = better)
fullMark: 6,
}));
}, [selectedReport]);
// ─── Attendance Parsed ─────────────────────────────────────────
const attendanceParsed = useMemo(() => {
if (!selectedReport?.attendanceSummary) return null;
try {
return JSON.parse(selectedReport.attendanceSummary);
} catch {
return null;
}
}, [selectedReport?.attendanceSummary]);
// ─── Permission Checks ─────────────────────────────────────────
const isAdmin = role === 'SUPER_ADMIN' || role === 'SCHOOL_ADMIN' || role === 'VICE_PRINCIPAL';
const isTeacher = role === 'TEACHER';
const isStudent = role === 'STUDENT';
const isParent = role === 'PARENT';
const canCreate = isAdmin || isTeacher;
const canEdit = isAdmin || isTeacher;
const canPublish = isAdmin;
const canDelete = isAdmin;
const canBatch = isAdmin;
// ─── Periods ────────────────────────────────────────────────────
const periods = ['Semester 1', 'Semester 2', 'Halbjahr 1', 'Halbjahr 2', 'Ganzjahr', '1. Halbjahr', '2. Halbjahr'];
// ─── Render ─────────────────────────────────────────────────────
return (
<div className="space-y-6 p-4 md:p-6">
{/* Gradient Header Banner */}
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="relative overflow-hidden rounded-xl bg-gradient-to-r from-emerald-600 via-teal-600 to-emerald-700 p-6 text-white shadow-lg shadow-emerald-500/20 dark:shadow-emerald-900/30"
>
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMjAiIGN5PSIyMCIgcj0iMSIgZmlsbD0icmdiYSgyNTUsMjU1LDI1NSwwLjA4KSIvPjwvc3ZnPg==')] opacity-60" />
<div className="relative flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-xl bg-white/20 backdrop-blur-sm flex items-center justify-center">
<FileText className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold">{t('report_card.title')}</h1>
<p className="text-emerald-100 text-sm">{t('report_card.subtitle')}</p>
</div>
</div>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.3, type: 'spring' }}
className="flex items-center gap-2 px-4 py-2 rounded-full bg-white/15 backdrop-blur-sm"
>
<FileText className="h-4 w-4" />
<span className="text-sm font-semibold"><AnimatedCounter value={stats.total} /> {t('report_card.total_reports')}</span>
</motion.div>
</div>
</motion.div>
{/* Header Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0 }}>
<Card className="bg-gradient-to-br from-emerald-500 to-teal-600 text-white border-0">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-emerald-100 text-xs font-medium">{t('report_card.total_reports')}</p>
<p className="text-2xl font-bold mt-1"><AnimatedCounter value={stats.total} /></p>
</div>
<FileText className="h-8 w-8 text-emerald-200 opacity-50" />
</div>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<Card className="bg-gradient-to-br from-gray-500 to-gray-600 text-white border-0">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-200 text-xs font-medium">{t('report_card.drafts')}</p>
<p className="text-2xl font-bold mt-1"><AnimatedCounter value={stats.drafts} /></p>
</div>
<Clock className="h-8 w-8 text-gray-300 opacity-50" />
</div>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
<Card className="bg-gradient-to-br from-amber-500 to-orange-500 text-white border-0">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-amber-100 text-xs font-medium">{t('report_card.in_review')}</p>
<p className="text-2xl font-bold mt-1"><AnimatedCounter value={stats.inReview} /></p>
</div>
<AlertCircle className="h-8 w-8 text-amber-200 opacity-50" />
</div>
</CardContent>
</Card>
</motion.div>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
<Card className="bg-gradient-to-br from-teal-500 to-emerald-600 text-white border-0">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-teal-100 text-xs font-medium">{t('report_card.published')}</p>
<p className="text-2xl font-bold mt-1"><AnimatedCounter value={stats.published} /></p>
</div>
<CheckCircle2 className="h-8 w-8 text-teal-200 opacity-50" />
</div>
</CardContent>
</Card>
</motion.div>
</div>
{/* List View */}
{viewMode === 'list' && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
{/* Toolbar */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col md:flex-row gap-3 items-start md:items-center justify-between">
<div className="flex flex-1 flex-col sm:flex-row gap-2 w-full">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('report_card.search_placeholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Select value={filterClass} onValueChange={setFilterClass}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder={t('report_card.filter_class')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('report_card.all_classes')}</SelectItem>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filterStatus} onValueChange={setFilterStatus}>
<SelectTrigger className="w-full sm:w-36">
<SelectValue placeholder={t('report_card.filter_status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('report_card.all_statuses')}</SelectItem>
<SelectItem value="DRAFT">{t('report_card.status_draft')}</SelectItem>
<SelectItem value="REVIEW">{t('report_card.status_review')}</SelectItem>
<SelectItem value="PUBLISHED">{t('report_card.status_published')}</SelectItem>
<SelectItem value="ARCHIVED">{t('report_card.status_archived')}</SelectItem>
</SelectContent>
</Select>
<Select value={filterPeriod} onValueChange={setFilterPeriod}>
<SelectTrigger className="w-full sm:w-36">
<SelectValue placeholder={t('report_card.filter_period')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('report_card.all_periods')}</SelectItem>
{periods.map((p) => (
<SelectItem key={p} value={p}>{p}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2 flex-wrap">
{canCreate && (
<Button onClick={() => setShowCreateDialog(true)} size="sm">
<Plus className="h-4 w-4 mr-1" />{t('report_card.create')}
</Button>
)}
{canBatch && (
<Button onClick={() => setShowBatchDialog(true)} variant="outline" size="sm">
<Users className="h-4 w-4 mr-1" />{t('report_card.batch_generate')}
</Button>
)}
{isAdmin && (
<Button onClick={() => setShowTemplateDialog(true)} variant="outline" size="sm">
<LayoutTemplate className="h-4 w-4 mr-1" />{t('report_card.manage_templates')}
</Button>
)}
</div>
</div>
{/* Bulk Actions */}
{selectedIds.size > 0 && (
<div className="mt-3 flex items-center gap-2 p-2 bg-muted rounded-lg">
<span className="text-sm text-muted-foreground">{selectedIds.size} {t('report_card.selected')}</span>
{canPublish && (
<Button size="sm" variant="outline" onClick={() => handleBulkAction('publish')}>
<Send className="h-3 w-3 mr-1" />{t('report_card.publish')}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => handleBulkAction('archive')}>
<Archive className="h-3 w-3 mr-1" />{t('report_card.archive')}
</Button>
{canDelete && (
<Button size="sm" variant="destructive" onClick={() => handleBulkAction('delete')}>
<Trash2 className="h-3 w-3 mr-1" />{t('action.delete')}
</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* Report Cards List */}
{isLoading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-emerald-500" />
</div>
) : filteredCards.length === 0 ? (
<Card>
<CardContent className="py-12 text-center">
<FileText className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
<p className="text-muted-foreground">{t('report_card.no_reports')}</p>
{canCreate && (
<Button onClick={() => setShowCreateDialog(true)} className="mt-4" variant="outline">
<Plus className="h-4 w-4 mr-1" />{t('report_card.create_first')}
</Button>
)}
</CardContent>
</Card>
) : (
<div className="space-y-3 mt-4">
<AnimatePresence>
{filteredCards.map((report, idx) => (
<motion.div
key={report.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ delay: idx * 0.03 }}
>
<Card className="hover:shadow-lg hover:shadow-emerald-500/5 hover:border-emerald-200/60 dark:hover:border-emerald-800/60 transition-all duration-300 hover:-translate-y-0.5 group">
<CardContent className="p-4">
<div className="flex items-start gap-3">
{(isAdmin || isTeacher) && (
<Checkbox
checked={selectedIds.has(report.id)}
onCheckedChange={(checked) => {
const next = new Set(selectedIds);
if (checked) next.add(report.id);
else next.delete(report.id);
setSelectedIds(next);
}}
className="mt-1"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-sm">
{report.student.firstName} {report.student.lastName}
</h3>
{getStatusBadge(report.status)}
<Badge variant="secondary" className="text-xs">
{report.classGroup.name}
</Badge>
<Badge variant="outline" className="text-xs">
{report.period}
</Badge>
</div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(report.generatedAt).toLocaleDateString('de-DE')}
</span>
<span className="flex items-center gap-1">
<UserCheck className="h-3 w-3" />
{report.generatedByUser.firstName} {report.generatedByUser.lastName}
</span>
{report.template && (
<span className="flex items-center gap-1">
<LayoutTemplate className="h-3 w-3" />
{report.template.name}
</span>
)}
{report.includesGrades && (
<span className="flex items-center gap-1">
<Award className="h-3 w-3" />
{t('report_card.with_grades')}
</span>
)}
</div>
{report.sections.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{report.sections.slice(0, 3).map((s) => (
<span
key={s.id}
className="text-xs px-2 py-0.5 rounded-full bg-muted"
style={s.competencyCategory?.color ? { borderLeft: `3px solid ${s.competencyCategory.color}` } : undefined}
>
{s.competencyCategory?.name ?? s.generatedText.slice(0, 30)}
</span>
))}
{report.sections.length > 3 && (
<span className="text-xs text-muted-foreground">
+{report.sections.length - 3} {t('report_card.more')}
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" onClick={() => openPreview(report)}>
<Eye className="h-4 w-4" />
</Button>
{canEdit && (
<Button size="sm" variant="ghost" onClick={() => openEditor(report)}>
<Edit3 className="h-4 w-4" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="ghost">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canEdit && report.status === 'DRAFT' && (
<DropdownMenuItem onClick={() => handleStatusChange(report.id, 'REVIEW')}>
<Send className="h-4 w-4 mr-2" />{t('report_card.submit_review')}
</DropdownMenuItem>
)}
{canPublish && report.status === 'REVIEW' && (
<DropdownMenuItem onClick={() => handleStatusChange(report.id, 'PUBLISHED')}>
<CheckCircle2 className="h-4 w-4 mr-2" />{t('report_card.publish')}
</DropdownMenuItem>
)}
{canPublish && report.status === 'PUBLISHED' && (
<DropdownMenuItem onClick={() => handleStatusChange(report.id, 'FINAL')}>
<Star className="h-4 w-4 mr-2" />{t('report_card.finalize')}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => handleExportPDF([report.id])}>
<Download className="h-4 w-4 mr-2" />{t('report_card.export_pdf')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handlePrintCurrent()}>
<Printer className="h-4 w-4 mr-2" />{t('action.print')}
</DropdownMenuItem>
{report.status !== 'ARCHIVED' && (
<DropdownMenuItem onClick={() => handleStatusChange(report.id, 'ARCHIVED')}>
<Archive className="h-4 w-4 mr-2" />{t('report_card.archive')}
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => { setDeleteTarget(report.id); setShowDeleteDialog(true); }}
>
<Trash2 className="h-4 w-4 mr-2" />{t('action.delete')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</AnimatePresence>
</div>
)}
</motion.div>
)}
{/* Editor View */}
{viewMode === 'editor' && selectedReport && (
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }}>
<div className="flex items-center gap-3 mb-4">
<Button variant="ghost" size="sm" onClick={() => setViewMode('list')}>
<ChevronRight className="h-4 w-4 rotate-180 mr-1" />{t('action.back')}
</Button>
<h2 className="text-lg font-semibold flex-1">
{t('report_card.edit_title')} - {selectedReport.student.firstName} {selectedReport.student.lastName}
</h2>
<div className="flex items-center gap-2">
{getStatusBadge(selectedReport.status)}
<Button size="sm" variant="outline" onClick={() => openPreview(selectedReport)}>
<Eye className="h-4 w-4 mr-1" />{t('action.preview')}
</Button>
<Button size="sm" onClick={handleSaveReport} disabled={isSaving}>
{isSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
{t('action.save')}
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: Main Editor */}
<div className="lg:col-span-2 space-y-4">
{/* Student Info */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<GraduationCap className="h-4 w-4" />{t('report_card.student_info')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div>
<Label className="text-xs text-muted-foreground">{t('report_card.student_name')}</Label>
<p className="font-medium text-sm">{selectedReport.student.firstName} {selectedReport.student.lastName}</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">{t('report_card.class_name')}</Label>
<p className="font-medium text-sm">{selectedReport.classGroup.name}</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">{t('report_card.school_year')}</Label>
<p className="font-medium text-sm">{selectedReport.schoolYear.label}</p>
</div>
<div>
<Label className="text-xs text-muted-foreground">{t('report_card.period')}</Label>
<p className="font-medium text-sm">{selectedReport.period}</p>
</div>
</div>
</CardContent>
</Card>
{/* Subject Grades Table */}
{selectedReport.computedGrades && selectedReport.computedGrades.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Award className="h-4 w-4" />{t('report_card.subject_grades')}
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 pr-4 font-medium">{t('report_card.subject')}</th>
<th className="text-center py-2 px-2 font-medium">{t('report_card.grade')}</th>
<th className="text-center py-2 px-2 font-medium">{t('report_card.label')}</th>
<th className="text-center py-2 pl-2 font-medium">{t('report_card.status_col')}</th>
</tr>
</thead>
<tbody>
{selectedReport.computedGrades.map((g) => {
const grade = g.overriddenValue ?? g.computedValue;
const rounded = Math.round(grade * 10) / 10;
const gradeLabel = GRADE_LABELS[Math.round(grade)] ?? '';
return (
<motion.tr key={g.id} className="border-b last:border-0 group/row"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
>
<td className="py-2 pr-4">{g.subject.name}</td>
<td className="py-2 px-2 text-center">
<motion.span
className={`inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-colors duration-500 ${getGradeColor(Math.round(grade))}`}
whileHover={{ scale: 1.15 }}
whileTap={{ scale: 0.95 }}
>
{rounded}
</motion.span>
</td>
<td className="py-2 px-2 text-center text-xs">{gradeLabel}</td>
<td className="py-2 pl-2 text-center">
{g.isFinalized ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500 mx-auto" />
) : (
<Clock className="h-4 w-4 text-amber-500 mx-auto" />
)}
</td>
</motion.tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Competency Sections */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<BookOpen className="h-4 w-4" />{t('report_card.competency_sections')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{editSections.map((section, idx) => (
<div key={section.id ?? idx} className="space-y-1">
<div className="flex items-center gap-2">
{section.competencyCategory?.color && (
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: section.competencyCategory.color }} />
)}
<Label className="text-xs font-medium">
{section.competencyCategory?.name ?? `${t('report_card.section')} ${idx + 1}`}
</Label>
</div>
<Textarea
value={section.generatedText}
onChange={(e) => {
const updated = [...editSections];
updated[idx] = { ...updated[idx], generatedText: e.target.value };
setEditSections(updated);
}}
rows={3}
className="text-sm"
/>
</div>
))}
{editSections.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">{t('report_card.no_sections')}</p>
)}
</CardContent>
</Card>
{/* Teacher Comments */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<MessageSquare className="h-4 w-4" />{t('report_card.teacher_comments')}
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={editTeacherComments}
onChange={(e) => setEditTeacherComments(e.target.value)}
placeholder={t('report_card.comments_placeholder')}
rows={4}
/>
</CardContent>
</Card>
{/* Overall Assessment */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<BarChart3 className="h-4 w-4" />{t('report_card.overall_assessment')}
</CardTitle>
</CardHeader>
<CardContent>
<Textarea
value={editOverallAssessment}
onChange={(e) => setEditOverallAssessment(e.target.value)}
placeholder={t('report_card.assessment_placeholder')}
rows={4}
/>
</CardContent>
</Card>
</div>
{/* Right: Sidebar */}
<div className="space-y-4">
{/* Competency Radar Chart */}
{radarData.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<BarChart3 className="h-4 w-4" />{t('report_card.competency_radar')}
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={250}>
<RadarChart data={radarData}>
<PolarGrid strokeDasharray="3 3" />
<PolarAngleAxis dataKey="subject" tick={{ fontSize: 10 }} />
<PolarRadiusAxis angle={30} domain={[0, 6]} tick={{ fontSize: 8 }} />
<Radar
name={t('report_card.competency_level')}
dataKey="grade"
stroke="#10b981"
fill="#10b981"
fillOpacity={0.3}
/>
<RechartsTooltip />
</RadarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{/* Attendance Summary */}
{attendanceParsed && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Calendar className="h-4 w-4" />{t('report_card.attendance_summary')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
{[
{ label: t('report_card.present'), value: attendanceParsed.present, total: attendanceParsed.total, color: 'bg-emerald-500' },
{ label: t('report_card.absent'), value: attendanceParsed.absent, total: attendanceParsed.total, color: 'bg-red-500' },
{ label: t('report_card.excused'), value: attendanceParsed.excused, total: attendanceParsed.total, color: 'bg-amber-500' },
{ label: t('report_card.late'), value: attendanceParsed.late, total: attendanceParsed.total, color: 'bg-orange-500' },
].map((item) => (
<div key={item.label} className="space-y-1">
<div className="flex justify-between text-xs">
<span>{item.label}</span>
<span className="font-medium">{item.value} / {item.total}</span>
</div>
<Progress
value={item.total > 0 ? (item.value / item.total) * 100 : 0}
className="h-2"
/>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Status Actions */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">{t('report_card.actions')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{selectedReport.status === 'DRAFT' && canEdit && (
<Button className="w-full" variant="outline" size="sm" onClick={() => handleStatusChange(selectedReport.id, 'REVIEW')}>
<Send className="h-4 w-4 mr-1" />{t('report_card.submit_review')}
</Button>
)}
{selectedReport.status === 'REVIEW' && canPublish && (
<Button className="w-full" size="sm" onClick={() => handleStatusChange(selectedReport.id, 'PUBLISHED')}>
<CheckCircle2 className="h-4 w-4 mr-1" />{t('report_card.publish')}
</Button>
)}
{selectedReport.status === 'PUBLISHED' && canPublish && (
<Button className="w-full" size="sm" variant="outline" onClick={() => handleStatusChange(selectedReport.id, 'FINAL')}>
<Star className="h-4 w-4 mr-1" />{t('report_card.finalize')}
</Button>
)}
<Button className="w-full" variant="outline" size="sm" onClick={() => handleExportPDF([selectedReport.id])}>
<Download className="h-4 w-4 mr-1" />{t('report_card.export_pdf')}
</Button>
<Button className="w-full" variant="outline" size="sm" onClick={handlePrintCurrent}>
<Printer className="h-4 w-4 mr-1" />{t('action.print')}
</Button>
{canDelete && (
<Button
className="w-full"
variant="destructive"
size="sm"
onClick={() => { setDeleteTarget(selectedReport.id); setShowDeleteDialog(true); }}
>
<Trash2 className="h-4 w-4 mr-1" />{t('action.delete')}
</Button>
)}
</CardContent>
</Card>
</div>
</div>
</motion.div>
)}
{/* Preview View */}
{viewMode === 'preview' && selectedReport && (
<motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }}>
<div className="flex items-center gap-3 mb-4 print:hidden">
<Button variant="ghost" size="sm" onClick={() => setViewMode('list')}>
<ChevronRight className="h-4 w-4 rotate-180 mr-1" />{t('action.back')}
</Button>
<h2 className="text-lg font-semibold flex-1">
{t('report_card.preview_title')} - {selectedReport.student.firstName} {selectedReport.student.lastName}
</h2>
<div className="flex items-center gap-2">
{canEdit && (
<Button size="sm" variant="outline" onClick={() => openEditor(selectedReport)}>
<Edit3 className="h-4 w-4 mr-1" />{t('action.edit')}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => handleExportPDF([selectedReport.id])}>
<Download className="h-4 w-4 mr-1" />{t('report_card.export_pdf')}
</Button>
<Button size="sm" onClick={handlePrintCurrent}>
<Printer className="h-4 w-4 mr-1" />{t('action.print')}
</Button>
</div>
</div>
{/* Print-Ready Report Card */}
<Card className="print:shadow-none print:border-0 relative overflow-hidden">
<div className="absolute inset-0 rounded-lg bg-gradient-to-br from-emerald-500/5 via-transparent to-teal-500/5 pointer-events-none" />
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-emerald-500 via-teal-500 to-emerald-600" />
<CardContent className="p-6 md:p-8 max-w-4xl mx-auto relative">
{/* School Header */}
<div className="text-center mb-6 border-b pb-4">
<div className="flex items-center justify-center gap-3 mb-2">
<GraduationCap className="h-8 w-8 text-emerald-600" />
<h1 className="text-2xl font-bold">{t('report_card.report_card_title')}</h1>
</div>
<p className="text-sm text-muted-foreground">{t('report_card.school_year')}: {selectedReport.schoolYear.label}</p>
</div>
{/* Student Info */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6 p-4 bg-muted/30 rounded-lg">
<div>
<p className="text-xs text-muted-foreground">{t('report_card.student_name')}</p>
<p className="font-semibold">{selectedReport.student.firstName} {selectedReport.student.lastName}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t('report_card.class_name')}</p>
<p className="font-semibold">{selectedReport.classGroup.name}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t('report_card.period')}</p>
<p className="font-semibold">{selectedReport.period}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t('report_card.date')}</p>
<p className="font-semibold">{new Date(selectedReport.generatedAt).toLocaleDateString('de-DE')}</p>
</div>
</div>
{/* Subject Grades */}
{selectedReport.computedGrades && selectedReport.computedGrades.length > 0 && (
<div className="mb-6">
<h3 className="font-semibold text-sm mb-3 border-b pb-2">{t('report_card.subject_grades')}</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 pr-4 font-medium">{t('report_card.subject')}</th>
<th className="text-center py-2 px-4 font-medium">{t('report_card.grade')}</th>
<th className="text-left py-2 pl-4 font-medium">{t('report_card.evaluation')}</th>
</tr>
</thead>
<tbody>
{selectedReport.computedGrades.map((g) => {
const grade = g.overriddenValue ?? g.computedValue;
const rounded = Math.round(grade * 10) / 10;
const gradeLabel = GRADE_LABELS[Math.round(grade)] ?? '';
return (
<tr key={g.id} className="border-b last:border-0">
<td className="py-2 pr-4">{g.subject.name}</td>
<td className="py-2 px-4 text-center">
<span className={`inline-flex items-center justify-center min-w-[2rem] h-8 rounded-lg text-sm font-bold border ${getGradeColor(Math.round(grade))} ${getGradeBorder(Math.round(grade))}`}>
{rounded}
</span>
</td>
<td className="py-2 pl-4 text-sm">{gradeLabel}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Competency Radar Chart */}
{radarData.length > 0 && (
<div className="mb-6">
<h3 className="font-semibold text-sm mb-3 border-b pb-2">{t('report_card.competency_overview')}</h3>
<div className="flex justify-center">
<ResponsiveContainer width="100%" height={300}>
<RadarChart data={radarData}>
<PolarGrid strokeDasharray="3 3" />
<PolarAngleAxis dataKey="subject" tick={{ fontSize: 11 }} />
<PolarRadiusAxis angle={30} domain={[0, 6]} tick={{ fontSize: 9 }} />
<Radar
name={t('report_card.competency_level')}
dataKey="grade"
stroke="#10b981"
fill="#10b981"
fillOpacity={0.3}
/>
<RechartsTooltip />
</RadarChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Competency Sections */}
{selectedReport.sections.length > 0 && (
<div className="mb-6">
<h3 className="font-semibold text-sm mb-3 border-b pb-2">{t('report_card.competency_details')}</h3>
<div className="space-y-3">
{selectedReport.sections.map((section) => (
<div key={section.id} className="flex gap-3">
{section.competencyCategory?.color && (
<div className="w-1 rounded-full shrink-0" style={{ backgroundColor: section.competencyCategory.color }} />
)}
<div>
{section.competencyCategory?.name && (
<p className="text-xs font-semibold text-muted-foreground">{section.competencyCategory.name}</p>
)}
<p className="text-sm">{section.generatedText}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Attendance */}
{attendanceParsed && (
<div className="mb-6">
<h3 className="font-semibold text-sm mb-3 border-b pb-2">{t('report_card.attendance_summary')}</h3>
<div className="grid grid-cols-4 gap-4 text-center">
<div>
<p className="text-2xl font-bold text-emerald-600">{attendanceParsed.present}</p>
<p className="text-xs text-muted-foreground">{t('report_card.present')}</p>
</div>
<div>
<p className="text-2xl font-bold text-red-600">{attendanceParsed.absent}</p>
<p className="text-xs text-muted-foreground">{t('report_card.absent')}</p>
</div>
<div>
<p className="text-2xl font-bold text-amber-600">{attendanceParsed.excused}</p>
<p className="text-xs text-muted-foreground">{t('report_card.excused')}</p>
</div>
<div>
<p className="text-2xl font-bold text-orange-600">{attendanceParsed.late}</p>
<p className="text-xs text-muted-foreground">{t('report_card.late')}</p>
</div>
</div>
</div>
)}
{/* Teacher Comments */}
{selectedReport.teacherComments && (
<div className="mb-6">
<h3 className="font-semibold text-sm mb-3 border-b pb-2">{t('report_card.teacher_comments')}</h3>
<p className="text-sm whitespace-pre-wrap">{selectedReport.teacherComments}</p>
</div>
)}
{/* Overall Assessment */}
{selectedReport.overallAssessment && (
<div className="mb-6">
<h3 className="font-semibold text-sm mb-3 border-b pb-2">{t('report_card.overall_assessment')}</h3>
<p className="text-sm whitespace-pre-wrap">{selectedReport.overallAssessment}</p>
</div>
)}
{/* Signature Area */}
<div className="mt-8 pt-6 border-t">
<div className="grid grid-cols-2 gap-8">
<div>
<p className="text-xs text-muted-foreground mb-8">{t('report_card.teacher_signature')}</p>
<div className="border-b border-gray-400" />
<p className="text-xs text-muted-foreground mt-1">
{selectedReport.generatedByUser.firstName} {selectedReport.generatedByUser.lastName}
</p>
</div>
<div>
<p className="text-xs text-muted-foreground mb-8">{t('report_card.parent_signature')}</p>
<div className="border-b border-gray-400" />
<p className="text-xs text-muted-foreground mt-1">{t('report_card.date')}</p>
</div>
</div>
</div>
{/* Grading Scale Legend */}
<div className="mt-6 p-3 bg-muted/30 rounded-lg text-xs">
<p className="font-semibold mb-1">{t('report_card.grading_scale')}</p>
<div className="flex flex-wrap gap-2">
{Object.entries(GRADE_LABELS).map(([num, label]) => (
<span key={num} className={`px-2 py-0.5 rounded ${getGradeColor(Number(num))}`}>
{num} - {label}
</span>
))}
</div>
</div>
</CardContent>
</Card>
</motion.div>
)}
{/* Create Report Card Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('report_card.create_title')}</DialogTitle>
<DialogDescription>{t('report_card.create_desc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t('report_card.class_name')}</Label>
<Select value={createForm.classGroupId} onValueChange={(v) => setCreateForm((f) => ({ ...f, classGroupId: v, studentId: '' }))}>
<SelectTrigger><SelectValue placeholder={t('report_card.select_class')} /></SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('report_card.student_name')}</Label>
<Select value={createForm.studentId} onValueChange={(v) => setCreateForm((f) => ({ ...f, studentId: v }))}>
<SelectTrigger><SelectValue placeholder={t('report_card.select_student')} /></SelectTrigger>
<SelectContent>
{students.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.firstName} {s.lastName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('report_card.school_year')}</Label>
<Select value={createForm.schoolYearId} onValueChange={(v) => setCreateForm((f) => ({ ...f, schoolYearId: v }))}>
<SelectTrigger><SelectValue placeholder={t('report_card.select_year')} /></SelectTrigger>
<SelectContent>
{schoolYears.map((y) => (
<SelectItem key={y.id} value={y.id}>{y.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('report_card.period')}</Label>
<Select value={createForm.period} onValueChange={(v) => setCreateForm((f) => ({ ...f, period: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{periods.map((p) => (
<SelectItem key={p} value={p}>{p}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{templates.length > 0 && (
<div>
<Label>{t('report_card.template')}</Label>
<Select value={createForm.templateId} onValueChange={(v) => setCreateForm((f) => ({ ...f, templateId: v }))}>
<SelectTrigger><SelectValue placeholder={t('report_card.select_template')} /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('report_card.no_template')}</SelectItem>
{templates.map((tp) => (
<SelectItem key={tp.id} value={tp.id}>{tp.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="includes-grades"
checked={createForm.includesGrades}
onCheckedChange={(checked) => setCreateForm((f) => ({ ...f, includesGrades: !!checked }))}
/>
<Label htmlFor="includes-grades">{t('report_card.include_grades')}</Label>
</div>
<div>
<Label>{t('report_card.teacher_comments')}</Label>
<Textarea
value={createForm.teacherComments}
onChange={(e) => setCreateForm((f) => ({ ...f, teacherComments: e.target.value }))}
placeholder={t('report_card.comments_placeholder')}
rows={3}
/>
</div>
<div>
<Label>{t('report_card.overall_assessment')}</Label>
<Textarea
value={createForm.overallAssessment}
onChange={(e) => setCreateForm((f) => ({ ...f, overallAssessment: e.target.value }))}
placeholder={t('report_card.assessment_placeholder')}
rows={3}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>{t('action.cancel')}</Button>
<Button onClick={handleCreateReport} disabled={!createForm.studentId || !createForm.classGroupId}>
<Plus className="h-4 w-4 mr-1" />{t('action.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Batch Generate Dialog */}
<Dialog open={showBatchDialog} onOpenChange={setShowBatchDialog}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('report_card.batch_generate_title')}</DialogTitle>
<DialogDescription>{t('report_card.batch_generate_desc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>{t('report_card.class_name')}</Label>
<Select value={batchForm.classGroupId} onValueChange={(v) => setBatchForm((f) => ({ ...f, classGroupId: v }))}>
<SelectTrigger><SelectValue placeholder={t('report_card.select_class')} /></SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('report_card.school_year')}</Label>
<Select value={batchForm.schoolYearId} onValueChange={(v) => setBatchForm((f) => ({ ...f, schoolYearId: v }))}>
<SelectTrigger><SelectValue placeholder={t('report_card.select_year')} /></SelectTrigger>
<SelectContent>
{schoolYears.map((y) => (
<SelectItem key={y.id} value={y.id}>{y.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('report_card.period')}</Label>
<Select value={batchForm.period} onValueChange={(v) => setBatchForm((f) => ({ ...f, period: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{periods.map((p) => (
<SelectItem key={p} value={p}>{p}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{templates.length > 0 && (
<div>
<Label>{t('report_card.template')}</Label>
<Select value={batchForm.templateId} onValueChange={(v) => setBatchForm((f) => ({ ...f, templateId: v }))}>
<SelectTrigger><SelectValue placeholder={t('report_card.select_template')} /></SelectTrigger>
<SelectContent>
<SelectItem value="none">{t('report_card.no_template')}</SelectItem>
{templates.map((tp) => (
<SelectItem key={tp.id} value={tp.id}>{tp.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="batch-grades"
checked={batchForm.includesGrades}
onCheckedChange={(checked) => setBatchForm((f) => ({ ...f, includesGrades: !!checked }))}
/>
<Label htmlFor="batch-grades">{t('report_card.include_grades')}</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowBatchDialog(false)}>{t('action.cancel')}</Button>
<Button onClick={handleBatchGenerate} disabled={!batchForm.classGroupId}>
<Users className="h-4 w-4 mr-1" />{t('report_card.generate')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Template Management Dialog */}
<Dialog open={showTemplateDialog} onOpenChange={setShowTemplateDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{t('report_card.template_management')}</DialogTitle>
<DialogDescription>{t('report_card.template_desc')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Existing Templates */}
{templates.length > 0 && (
<div className="space-y-2">
<Label className="text-xs font-medium">{t('report_card.existing_templates')}</Label>
{templates.map((tp) => (
<div key={tp.id} className="flex items-center gap-2 p-2 rounded-lg border">
<LayoutTemplate className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{tp.name}</p>
<p className="text-xs text-muted-foreground">{tp.layout} layout</p>
</div>
{tp.isDefault && <Badge variant="secondary" className="text-xs">{t('report_card.default')}</Badge>}
</div>
))}
</div>
)}
<Separator />
{/* New Template Form */}
<div>
<Label>{t('report_card.template_name')}</Label>
<Input
value={templateForm.name}
onChange={(e) => setTemplateForm((f) => ({ ...f, name: e.target.value }))}
placeholder={t('report_card.template_name_placeholder')}
/>
</div>
<div>
<Label>{t('report_card.template_description')}</Label>
<Input
value={templateForm.description}
onChange={(e) => setTemplateForm((f) => ({ ...f, description: e.target.value }))}
placeholder={t('report_card.template_desc_placeholder')}
/>
</div>
<div>
<Label>{t('report_card.layout')}</Label>
<Select value={templateForm.layout} onValueChange={(v) => setTemplateForm((f) => ({ ...f, layout: v as 'default' | 'detailed' | 'compact' }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="default">{t('report_card.layout_default')}</SelectItem>
<SelectItem value="detailed">{t('report_card.layout_detailed')}</SelectItem>
<SelectItem value="compact">{t('report_card.layout_compact')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="template-default"
checked={templateForm.isDefault}
onCheckedChange={(checked) => setTemplateForm((f) => ({ ...f, isDefault: !!checked }))}
/>
<Label htmlFor="template-default">{t('report_card.set_as_default')}</Label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowTemplateDialog(false)}>{t('action.cancel')}</Button>
<Button onClick={handleCreateTemplate} disabled={!templateForm.name}>
<Plus className="h-4 w-4 mr-1" />{t('report_card.create_template')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('report_card.delete_confirm_title')}</AlertDialogTitle>
<AlertDialogDescription>{t('report_card.delete_confirm_desc')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('action.cancel')}</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{t('action.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
// ─── Print HTML Generator ────────────────────────────────────────────
function generatePrintHTML(
reports: ReportCard[],
school?: { name: string; motto?: string; logoUrl?: string; address?: string; phone?: string; primaryColor?: string }
): string {
const gradeLabels: Record<number, string> = {
1: 'Sehr gut', 2: 'Gut', 3: 'Befriedigend', 4: 'Ausreichend', 5: 'Mangelhaft', 6: 'Ungenügend',
};
function getGradeColor(grade: number): string {
if (grade <= 1) return '#059669';
if (grade <= 2) return '#16a34a';
if (grade <= 3) return '#ca8a04';
if (grade <= 4) return '#d97706';
if (grade <= 5) return '#ea580c';
return '#dc2626';
}
const reportCards = reports.map((report) => {
const grades = report.computedGrades ?? [];
const attendance = report.attendanceSummary ? JSON.parse(report.attendanceSummary) : null;
return `
<div class="report-card-page">
<div class="header">
<h1>${school?.name ?? 'CompetenceTrack'}</h1>
${school?.motto ? `<p class="motto">${school.motto}</p>` : ''}
<h2>Zeugnis / Report Card</h2>
<p>Schuljahr: ${report.schoolYear.label}</p>
</div>
<div class="student-info">
<div><strong>Name:</strong> ${report.student.firstName} ${report.student.lastName}</div>
<div><strong>Klasse:</strong> ${report.classGroup.name}</div>
<div><strong>Zeitraum:</strong> ${report.period}</div>
<div><strong>Datum:</strong> ${new Date(report.generatedAt).toLocaleDateString('de-DE')}</div>
</div>
${grades.length > 0 ? `
<h3>Noten / Grades</h3>
<table>
<thead>
<tr><th>Fach</th><th>Note</th><th>Bewertung</th></tr>
</thead>
<tbody>
${grades.map((g) => {
const grade = g.overriddenValue ?? g.computedValue;
const rounded = Math.round(grade * 10) / 10;
const label = gradeLabels[Math.round(grade)] ?? '';
return `<tr><td>${g.subject.name}</td><td style="color: ${getGradeColor(Math.round(grade))}; font-weight: bold;">${rounded}</td><td>${label}</td></tr>`;
}).join('')}
</tbody>
</table>
` : ''}
${report.sections.length > 0 ? `
<h3>Kompetenzbereiche / Competencies</h3>
${report.sections.map((s) => `
<div class="section">
${s.competencyCategory?.name ? `<strong>${s.competencyCategory.name}</strong><br/>` : ''}
${s.generatedText}
</div>
`).join('')}
` : ''}
${attendance ? `
<h3>Anwesenheit / Attendance</h3>
<div class="attendance-grid">
<div><strong>${attendance.present}</strong> Anwesend</div>
<div><strong>${attendance.absent}</strong> Fehlend</div>
<div><strong>${attendance.excused}</strong> Entschuldigt</div>
<div><strong>${attendance.late}</strong> Verspätet</div>
</div>
` : ''}
${report.teacherComments ? `
<h3>Bemerkungen / Comments</h3>
<p>${report.teacherComments}</p>
` : ''}
${report.overallAssessment ? `
<h3>Gesamtbewertung / Overall Assessment</h3>
<p>${report.overallAssessment}</p>
` : ''}
<div class="signature-area">
<div class="signature-box">
<div class="signature-line"></div>
<p>Lehrkraft / Teacher</p>
</div>
<div class="signature-box">
<div class="signature-line"></div>
<p>Eltern / Parent</p>
</div>
</div>
<div class="grading-scale">
<p><strong>Notenskala:</strong> 1 - Sehr gut | 2 - Gut | 3 - Befriedigend | 4 - Ausreichend | 5 - Mangelhaft | 6 - Ungenügend</p>
</div>
<div class="page-break"></div>
</div>
`;
}).join('');
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8"/>
<title>Report Card Export</title>
<style>
@page { size: A4; margin: 20mm; }
body { font-family: 'Inter', 'Helvetica', 'Arial', sans-serif; font-size: 11pt; line-height: 1.5; color: #1a1a1a; margin: 0; padding: 0; }
.report-card-page { max-width: 700px; margin: 0 auto; }
.header { text-align: center; margin-bottom: 24px; border-bottom: 2px solid #10b981; padding-bottom: 16px; }
.header h1 { font-size: 18pt; margin: 0; color: #10b981; }
.header h2 { font-size: 14pt; margin: 8px 0 4px; }
.header .motto { font-style: italic; color: #666; font-size: 10pt; }
.header p { margin: 4px 0; font-size: 10pt; color: #666; }
.student-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 16px 0; padding: 12px; background: #f9fafb; border-radius: 6px; }
h3 { font-size: 12pt; color: #10b981; border-bottom: 1px solid #e5e7eb; padding-bottom: 4px; margin-top: 20px; }
table { width: 100%; border-collapse: collapse; margin: 8px 0; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #e5e7eb; }
th { background: #f9fafb; font-weight: 600; font-size: 10pt; }
.section { padding: 8px 0; border-bottom: 1px dotted #e5e7eb; }
.attendance-grid { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 8px; text-align: center; }
.signature-area { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 40px; }
.signature-box { text-align: center; }
.signature-line { border-bottom: 1px solid #333; margin-bottom: 8px; height: 30px; }
.grading-scale { margin-top: 24px; font-size: 9pt; color: #666; border-top: 1px solid #e5e7eb; padding-top: 8px; }
.page-break { page-break-after: always; }
</style>
</head>
<body>
${reportCards}
</body>
</html>`;
}