Files
schulos/src/components/mastery-matrix-view.tsx
T
Mustafa Cetin 0c1f739989 Initial commit: ExaMetra v1.0.0 — Free & open-source competence & grading platform
Features:
- 15+ views: Dashboard, Classes, Progress, Flower, Matrix, Assessments, Grading, Reports, Attendance, Calendar, Lesson Plans, Analytics, Parent Communication, Behavior Tracking, Settings
- 34+ API endpoints
- 30+ Prisma models
- 700+ i18n keys (German + English)
- Keyboard shortcuts (Cmd+K, Cmd+1-9, Cmd+/)
- Batch deletion, drag-and-drop seating, custom color palette
- Demo accounts (admin, teacher, student, parent)
- Dark mode, responsive design, print styles
- CSV/PDF export, notification system, onboarding tour
- Zero emojis — all replaced with Lucide icons
- MIT License
2026-07-29 05:46:10 +00:00

781 lines
39 KiB
TypeScript

'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { motion } from 'framer-motion';
import {
Grid3X3,
RefreshCw,
Download,
Layers,
Users,
CheckCircle2,
XCircle,
BarChart3,
Calendar,
BookOpen,
ListChecks,
Sprout,
Leaf,
TreePine,
Trees,
} 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 { Skeleton } from '@/components/ui/skeleton';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import {
fetchMasteryMatrix,
fetchClasses,
fetchSubjects,
fetchLearningProgress,
type MasteryMatrixData,
type ClassGroup,
type Subject,
type LearningProgressEntry,
} from '@/lib/api';
import { toast } from 'sonner';
function masteryCellColor(level: number | null): string {
if (level === null) return 'bg-gray-100 dark:bg-gray-800/60 text-gray-400';
if (level <= 1) return 'bg-red-200 dark:bg-red-900/40 text-red-800 dark:text-red-200';
if (level <= 2) return 'bg-amber-200 dark:bg-amber-900/40 text-amber-800 dark:text-amber-200';
if (level <= 3) return 'bg-emerald-200 dark:bg-emerald-900/40 text-emerald-800 dark:text-emerald-200';
return 'bg-teal-200 dark:bg-teal-900/40 text-teal-800 dark:text-teal-200';
}
function avgColor(avg: number): string {
if (avg <= 0) return 'text-gray-400';
if (avg < 2) return 'text-rose-600 dark:text-rose-400';
if (avg < 3) return 'text-amber-600 dark:text-amber-400';
if (avg < 3.5) return 'text-emerald-600 dark:text-emerald-400';
return 'text-teal-600 dark:text-teal-400';
}
function escapeCsv(field: string | number | null | undefined): string {
if (field === null || field === undefined) return '';
const str = String(field);
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
export default function MasteryMatrixView() {
const currentUser = useAppStore((s) => s.currentUser);
const schoolYearId = useAppStore((s) => s.schoolYearId);
const storeClassId = useAppStore((s) => s.currentClassId);
const [classes, setClasses] = useState<ClassGroup[]>([]);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [selectedClassId, setSelectedClassId] = useState<string>(storeClassId ?? '');
const [selectedSubjectId, setSelectedSubjectId] = useState<string>('');
const [data, setData] = useState<MasteryMatrixData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [detail, setDetail] = useState<{ studentName: string; competencyCode: string; competencyTitle: string; entries: LearningProgressEntry[] } | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// Load classes + subjects for filters
useEffect(() => {
async function load() {
try {
const [cls, subs] = await Promise.all([
fetchClasses(currentUser?.schoolId ?? undefined, schoolYearId ?? undefined).catch(() => []),
fetchSubjects(currentUser?.schoolId ?? undefined).catch(() => []),
]);
setClasses(cls);
setSubjects(subs);
if (!selectedClassId && cls.length > 0) {
setSelectedClassId(cls[0].id);
}
} catch {
// ignore
}
}
load();
}, [currentUser?.schoolId, schoolYearId]);
const loadMatrix = React.useCallback(async () => {
if (!selectedClassId || !selectedSubjectId) {
setData(null);
return;
}
setLoading(true);
setError(null);
try {
const d = await fetchMasteryMatrix({
classGroupId: selectedClassId,
subjectId: selectedSubjectId,
});
setData(d);
} catch (err) {
const msg = err instanceof Error ? err.message : t('error.generic');
setError(msg);
toast.error(msg);
} finally {
setLoading(false);
}
}, [selectedClassId, selectedSubjectId]);
useEffect(() => {
loadMatrix();
}, [loadMatrix]);
// Build matrix lookup map: `${studentId}::${competencyId}` -> cell
const matrixMap = useMemo(() => {
const m = new Map<string, { level: number | null; count: number; lastDate: string | null }>();
if (data) {
for (const cell of data.matrix) {
m.set(`${cell.studentId}::${cell.competencyId}`, {
level: cell.latestMasteryLevel,
count: cell.entryCount,
lastDate: cell.lastEntryDate,
});
}
}
return m;
}, [data]);
// Row averages (per student) and column averages (per competency)
const rowAverages = useMemo(() => {
if (!data) return new Map<string, number>();
const m = new Map<string, { total: number; count: number }>();
for (const student of data.students) {
m.set(student.id, { total: 0, count: 0 });
}
for (const cell of data.matrix) {
if (cell.latestMasteryLevel !== null) {
const cur = m.get(cell.studentId);
if (cur) {
cur.total += cell.latestMasteryLevel;
cur.count += 1;
}
}
}
const result = new Map<string, number>();
for (const [sid, v] of m) {
result.set(sid, v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0);
}
return result;
}, [data]);
const colAverages = useMemo(() => {
if (!data) return new Map<string, number>();
const m = new Map<string, { total: number; count: number }>();
for (const comp of data.competencies) {
m.set(comp.id, { total: 0, count: 0 });
}
for (const cell of data.matrix) {
if (cell.latestMasteryLevel !== null) {
const cur = m.get(cell.competencyId);
if (cur) {
cur.total += cell.latestMasteryLevel;
cur.count += 1;
}
}
}
const result = new Map<string, number>();
for (const [cid, v] of m) {
result.set(cid, v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0);
}
return result;
}, [data]);
const overallAvg = useMemo(() => {
if (!data || data.matrix.length === 0) return 0;
const assessed = data.matrix.filter((c) => c.latestMasteryLevel !== null);
if (assessed.length === 0) return 0;
return Math.round((assessed.reduce((s, c) => s + (c.latestMasteryLevel ?? 0), 0) / assessed.length) * 100) / 100;
}, [data]);
// Total entries across all cells
const totalEntries = useMemo(() => {
if (!data) return 0;
return data.matrix.reduce((s, c) => s + c.entryCount, 0);
}, [data]);
// Coverage %: how many cells have at least one entry
const coveragePct = useMemo(() => {
if (!data || data.students.length === 0 || data.competencies.length === 0) return 0;
const totalCells = data.students.length * data.competencies.length;
if (totalCells === 0) return 0;
const assessed = data.matrix.filter((c) => c.latestMasteryLevel !== null).length;
return Math.round((assessed / totalCells) * 100);
}, [data]);
// Mastery distribution across the whole matrix (count per level)
const masteryDist = useMemo(() => {
if (!data) return [0, 0, 0, 0];
const counts = [0, 0, 0, 0];
for (const cell of data.matrix) {
if (cell.latestMasteryLevel !== null && cell.latestMasteryLevel >= 1 && cell.latestMasteryLevel <= 4) {
counts[cell.latestMasteryLevel - 1] += 1;
}
}
return counts;
}, [data]);
const handleCellClick = async (studentId: string, competencyId: string) => {
if (!data) return;
const student = data.students.find((s) => s.id === studentId);
const comp = data.competencies.find((c) => c.id === competencyId);
if (!student || !comp) return;
setDetailLoading(true);
setDetail({
studentName: `${student.firstName} ${student.lastName}`,
competencyCode: comp.code,
competencyTitle: comp.title,
entries: [],
});
try {
const entries = await fetchLearningProgress({
studentId,
competencyId,
});
setDetail({
studentName: `${student.firstName} ${student.lastName}`,
competencyCode: comp.code,
competencyTitle: comp.title,
entries,
});
} catch (err) {
const msg = err instanceof Error ? err.message : t('error.generic');
toast.error(msg);
setDetail(null);
} finally {
setDetailLoading(false);
}
};
const handleExportCsv = () => {
if (!data) return;
const header = [
t('matrix.student'),
...data.competencies.map((c) => c.code),
t('matrix.row_avg'),
];
let csv = header.map(escapeCsv).join(',') + '\n';
for (const s of data.students) {
const row: (string | number)[] = [`${s.firstName} ${s.lastName}`];
for (const c of data.competencies) {
const cell = matrixMap.get(`${s.id}::${c.id}`);
row.push(cell?.level ?? '');
}
row.push(rowAverages.get(s.id) ?? 0);
csv += row.map(escapeCsv).join(',') + '\n';
}
// Column averages row
const avgRow: (string | number)[] = [t('matrix.col_avg')];
for (const c of data.competencies) {
avgRow.push(colAverages.get(c.id) ?? 0);
}
avgRow.push(overallAvg);
csv += avgRow.map(escapeCsv).join(',') + '\n';
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mastery_matrix_${selectedClassId}_${selectedSubjectId}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(t('matrix.export_success'));
};
const hasFilters = selectedClassId && selectedSubjectId;
return (
<div className="space-y-6">
{/* Header */}
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}>
<Card className="border-0 shadow-sm rounded-xl overflow-hidden relative">
{/* Decorative gradient banner */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-100/40 via-emerald-50/30 to-violet-100/40 dark:from-teal-900/15 dark:via-emerald-900/10 dark:to-violet-900/15 pointer-events-none" />
<div className="absolute inset-0 bg-pattern-grid opacity-30 pointer-events-none" />
<CardContent className="p-5 relative">
<div className="flex items-start gap-4 flex-wrap">
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-gradient-to-br from-teal-500 to-emerald-600 text-white ring-2 ring-teal-200/60 dark:ring-teal-800/40 shadow-lg shadow-teal-300/30 dark:shadow-teal-900/40">
<Grid3X3 className="h-6 w-6" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-bold gradient-text">{t('matrix.title')}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5 max-w-2xl">{t('matrix.subtitle')}</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="rounded-xl border-teal-300 dark:border-teal-700 text-teal-700 dark:text-teal-300 backdrop-blur-sm bg-white/60 dark:bg-gray-900/40 disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none"
onClick={loadMatrix}
disabled={loading || !hasFilters}
>
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${loading ? 'animate-spin' : ''}`} />
{t('analytics.refresh')}
</Button>
<Button
variant="outline"
size="sm"
className="rounded-xl border-emerald-300 dark:border-emerald-700 text-emerald-700 dark:text-emerald-300 backdrop-blur-sm bg-white/60 dark:bg-gray-900/40 disabled:opacity-40 disabled:cursor-not-allowed disabled:pointer-events-none"
onClick={handleExportCsv}
disabled={!data || data.students.length === 0}
>
<Download className="h-3.5 w-3.5 mr-1" />
{t('matrix.export_csv')}
</Button>
</div>
</div>
{/* Filters */}
<div className="mt-4 flex flex-wrap gap-3 items-end">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-teal-600/70 dark:text-teal-400/50 font-semibold">
{t('matrix.select_class')}
</label>
<Select value={selectedClassId} onValueChange={setSelectedClassId}>
<SelectTrigger className="h-8 w-44 rounded-lg text-xs border-teal-200 dark:border-teal-900/30">
<SelectValue placeholder={t('matrix.select_class')} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-teal-600/70 dark:text-teal-400/50 font-semibold">
{t('matrix.select_subject')}
</label>
<Select value={selectedSubjectId} onValueChange={setSelectedSubjectId}>
<SelectTrigger className="h-8 w-44 rounded-lg text-xs border-teal-200 dark:border-teal-900/30">
<SelectValue placeholder={t('matrix.select_subject')} />
</SelectTrigger>
<SelectContent>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Summary banner */}
{data && (
<div className="mt-4 p-4 rounded-xl bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm border border-teal-200/40 dark:border-teal-900/30">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{/* Class average */}
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-teal-400 to-emerald-500 text-white shadow-sm shrink-0">
<Grid3X3 className="h-4 w-4" />
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-teal-600/70 dark:text-teal-400/60 font-semibold">{t('matrix.avg')}</p>
<p className={`text-lg font-bold ${avgColor(overallAvg)}`}>{overallAvg > 0 ? overallAvg.toFixed(2) : '—'}</p>
</div>
</div>
{/* Total entries */}
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-emerald-400 to-teal-500 text-white shadow-sm shrink-0">
<Layers className="h-4 w-4" />
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-emerald-600/70 dark:text-emerald-400/60 font-semibold">{t('matrix.entries')}</p>
<p className="text-lg font-bold text-emerald-700 dark:text-emerald-300">{totalEntries}</p>
</div>
</div>
{/* Coverage */}
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-amber-400 to-amber-500 text-white shadow-sm shrink-0">
<Users className="h-4 w-4" />
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-amber-600/70 dark:text-amber-400/60 font-semibold">{t('polish.coverage')}</p>
<p className="text-lg font-bold text-amber-700 dark:text-amber-300">{coveragePct}%</p>
</div>
</div>
{/* Mastery distribution mini bar chart */}
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-gradient-to-br from-violet-400 to-rose-500 text-white shadow-sm shrink-0">
<BarChart3 className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[10px] uppercase tracking-wider text-violet-600/70 dark:text-violet-400/60 font-semibold">{t('polish.mastery_distribution')}</p>
<div className="flex items-end gap-0.5 h-5 mt-0.5">
{masteryDist.map((cnt, i) => {
const max = Math.max(...masteryDist, 1);
const h = (cnt / max) * 100;
const colors = ['bg-red-400 dark:bg-red-500', 'bg-amber-400 dark:bg-amber-500', 'bg-emerald-400 dark:bg-emerald-500', 'bg-teal-400 dark:bg-teal-500'];
return (
<div
key={i}
className={`flex-1 rounded-t-sm transition-all hover:brightness-110 ${colors[i]}`}
style={{ height: `${Math.max(h, 8)}%` }}
title={`${t('polish.level_' + (i + 1))}: ${cnt}`}
/>
);
})}
</div>
</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</motion.div>
{!hasFilters ? (
<div className="space-y-4">
<Card className="border-0 shadow-sm rounded-xl">
<CardContent className="py-16 text-center">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-teal-100 to-emerald-100 dark:from-teal-900/30 dark:to-emerald-900/30 mx-auto mb-5 shadow-md shadow-teal-200/40 dark:shadow-teal-900/20">
<Grid3X3 className="h-10 w-10 text-teal-500 dark:text-teal-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_matrix')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('polish.empty_subtitle_matrix')}</p>
</CardContent>
</Card>
{/* How it works — 3-step guide */}
<Card className="border-0 shadow-sm rounded-xl border-l-3 border-l-teal-400 overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-base font-bold flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-400">
<ListChecks className="h-4 w-4" />
</div>
{t('polish.matrix_how_it_works_title')}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400 ml-1 hidden sm:inline">
· {t('polish.matrix_how_it_works_subtitle')}
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* Step 1 */}
<div className="relative p-4 rounded-xl bg-gradient-to-br from-emerald-50/60 to-emerald-50/0 dark:from-emerald-900/15 dark:to-emerald-900/0 border border-emerald-100/60 dark:border-emerald-900/30">
<span className="absolute top-3 right-3 flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-500 text-white text-xs font-bold shadow-sm">
1
</span>
<div className="flex items-center gap-2 mb-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-300">
<Users className="h-4 w-4" />
</div>
<p className="text-sm font-semibold text-emerald-700 dark:text-emerald-300">
{t('polish.matrix_step_1_title')}
</p>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-snug">
{t('polish.matrix_step_1_desc')}
</p>
</div>
{/* Step 2 */}
<div className="relative p-4 rounded-xl bg-gradient-to-br 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">
<span className="absolute top-3 right-3 flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-teal-400 to-teal-500 text-white text-xs font-bold shadow-sm">
2
</span>
<div className="flex items-center gap-2 mb-2">
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-teal-100 dark:bg-teal-900/30 text-teal-600 dark:text-teal-300">
<BookOpen className="h-4 w-4" />
</div>
<p className="text-sm font-semibold text-teal-700 dark:text-teal-300">
{t('polish.matrix_step_2_title')}
</p>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-snug">
{t('polish.matrix_step_2_desc')}
</p>
</div>
{/* Step 3 */}
<div className="relative p-4 rounded-xl bg-gradient-to-br 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">
<span className="absolute top-3 right-3 flex items-center justify-center w-7 h-7 rounded-full bg-gradient-to-br from-amber-400 to-amber-500 text-white text-xs font-bold shadow-sm">
3
</span>
<div className="flex items-center gap-2 mb-2">
<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-300">
<Grid3X3 className="h-4 w-4" />
</div>
<p className="text-sm font-semibold text-amber-700 dark:text-amber-300">
{t('polish.matrix_step_3_title')}
</p>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400 leading-snug">
{t('polish.matrix_step_3_desc')}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
) : loading ? (
<Skeleton className="h-96 rounded-xl" />
) : error ? (
<Card className="border-0 shadow-sm rounded-xl">
<CardContent className="py-12 text-center">
<p className="text-rose-600 dark:text-rose-400">{error}</p>
<Button variant="outline" className="mt-4 rounded-xl" onClick={loadMatrix}>
<RefreshCw className="h-4 w-4 mr-1" />
{t('analytics.refresh')}
</Button>
</CardContent>
</Card>
) : !data ? null : data.competencies.length === 0 ? (
<Card className="border-0 shadow-sm rounded-xl">
<CardContent className="py-16 text-center">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-rose-100 to-amber-100 dark:from-rose-900/30 dark:to-amber-900/20 mx-auto mb-5 shadow-md shadow-rose-200/40 dark:shadow-rose-900/20">
<XCircle className="h-10 w-10 text-rose-500 dark:text-rose-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_no_data')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('matrix.no_template_hint')}</p>
</CardContent>
</Card>
) : data.students.length === 0 ? (
<Card className="border-0 shadow-sm rounded-xl">
<CardContent className="py-16 text-center">
<div className="flex items-center justify-center w-20 h-20 rounded-3xl bg-gradient-to-br from-teal-100 to-emerald-100 dark:from-teal-900/30 dark:to-emerald-900/30 mx-auto mb-5 shadow-md shadow-teal-200/40 dark:shadow-teal-900/20">
<Users className="h-10 w-10 text-teal-500 dark:text-teal-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1">{t('polish.empty_title_no_data')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">{t('matrix.no_students')}</p>
</CardContent>
</Card>
) : (
<Card className="border-0 shadow-sm rounded-xl overflow-hidden">
<CardHeader className="pb-3 pt-6 bg-gradient-to-r from-teal-50/50 to-transparent dark:from-teal-900/10 dark:to-transparent">
<CardTitle className="text-lg font-bold flex items-center gap-2">
<Grid3X3 className="h-4 w-4 text-teal-600 dark:text-teal-400" />
{t('matrix.title')}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto scrollbar-education">
<Table className="text-xs">
<TableHeader>
<TableRow className="border-b-2 border-teal-300/40 dark:border-teal-900/40 sticky top-0 z-10 bg-gradient-to-r from-teal-50 via-white to-emerald-50/30 dark:from-teal-900/30 dark:via-gray-950 dark:to-emerald-900/10 shadow-sm">
<TableHead className="text-xs uppercase text-teal-600/80 dark:text-teal-400/60 sticky left-0 bg-inherit z-20 min-w-[140px] font-bold tracking-wider">
{t('matrix.student')}
</TableHead>
{data.competencies.map((c) => (
<TableHead
key={c.id}
className="text-center text-[10px] uppercase text-teal-600/70 dark:text-teal-400/50 min-w-[60px] max-w-[80px] px-2 hover:bg-teal-100/40 dark:hover:bg-teal-900/20 transition-colors"
title={`${c.code} — ${c.title}`}
>
<div className="flex flex-col items-center gap-0.5">
<span className="font-mono font-semibold truncate w-full text-center">{c.code}</span>
<span
className="inline-block w-1.5 h-1.5 rounded-full ring-1 ring-white dark:ring-gray-900"
style={{ backgroundColor: c.category.color ?? '#94a3b8' }}
title={c.category.name}
/>
</div>
</TableHead>
))}
<TableHead className="text-xs uppercase text-emerald-600/80 dark:text-emerald-400/60 sticky right-0 bg-inherit z-20 min-w-[60px] text-center font-bold tracking-wider">
{t('matrix.row_avg')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.students.map((s, sIdx) => {
const rowAvg = rowAverages.get(s.id) ?? 0;
return (
<TableRow
key={s.id}
className={sIdx % 2 === 1 ? 'bg-teal-50/20 dark:bg-teal-900/5 hover:bg-teal-50/40 dark:hover:bg-teal-900/10' : 'hover:bg-teal-50/30 dark:hover:bg-teal-900/8'}
>
<TableCell className="font-medium text-gray-900 dark:text-gray-100 sticky left-0 bg-inherit z-10 min-w-[140px]">
{s.firstName} {s.lastName}
</TableCell>
{data.competencies.map((c) => {
const cell = matrixMap.get(`${s.id}::${c.id}`);
const level = cell?.level ?? null;
const lastDate = cell?.lastDate;
const dateStr = lastDate ? new Date(lastDate).toLocaleDateString() : null;
const count = cell?.count ?? 0;
return (
<TableCell
key={c.id}
className="p-1 text-center"
>
<button
onClick={() => handleCellClick(s.id, c.id)}
className={`w-9 h-9 rounded-md flex items-center justify-center text-xs font-bold transition-all hover:scale-110 hover:shadow-md hover:ring-2 hover:ring-emerald-300/60 dark:hover:ring-emerald-700/40 cursor-pointer border border-white/40 dark:border-gray-800/40 ${masteryCellColor(level)}`}
title={
level !== null
? `${c.code} · ${t('analytics.level')} ${level}\n${t('matrix.last_entry')}: ${dateStr ?? '—'}\n${count} ${t('analytics.entry_count')}`
: `${c.code} · ${t('matrix.legend_unassessed')}`
}
>
{level ?? '—'}
</button>
</TableCell>
);
})}
<TableCell className="sticky right-0 bg-inherit z-10 text-center min-w-[60px]">
<span className={`text-sm font-bold ${avgColor(rowAvg)}`}>
{rowAvg > 0 ? rowAvg.toFixed(2) : '—'}
</span>
</TableCell>
</TableRow>
);
})}
{/* Column averages row */}
<TableRow className="border-t-2 border-emerald-300/40 dark:border-emerald-900/40 bg-gradient-to-r from-emerald-50/60 to-teal-50/30 dark:from-emerald-900/15 dark:to-teal-900/10">
<TableCell className="sticky left-0 bg-inherit z-10 font-bold text-emerald-700 dark:text-emerald-300 text-xs uppercase">
{t('matrix.col_avg')}
</TableCell>
{data.competencies.map((c) => {
const avg = colAverages.get(c.id) ?? 0;
return (
<TableCell key={c.id} className="text-center p-1">
<span className={`text-xs font-semibold ${avgColor(avg)}`}>
{avg > 0 ? avg.toFixed(1) : '—'}
</span>
</TableCell>
);
})}
<TableCell className="sticky right-0 bg-inherit z-10 text-center">
<span className={`text-sm font-bold ${avgColor(overallAvg)}`}>
{overallAvg > 0 ? overallAvg.toFixed(2) : '—'}
</span>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
{/* Enhanced Legend */}
<div className="p-4 border-t border-teal-200/40 dark:border-teal-900/30 bg-gradient-to-r from-emerald-50/30 to-teal-50/20 dark:from-emerald-900/10 dark:to-teal-900/10">
<div className="flex flex-wrap items-center gap-3 text-[11px]">
<span className="font-semibold uppercase tracking-wider text-teal-600/80 dark:text-teal-400/60 inline-flex items-center gap-1.5">
<Layers className="h-3 w-3" />
{t('matrix.legend')}:
</span>
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-gray-800/40 border border-gray-200/40 dark:border-gray-700/30">
<span className="inline-block w-3 h-3 rounded-sm bg-gray-200 dark:bg-gray-700 ring-1 ring-white dark:ring-gray-900" />
<span className="text-gray-600 dark:text-gray-300">{t('matrix.legend_unassessed')}</span>
</span>
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-gray-800/40 border border-gray-200/40 dark:border-gray-700/30">
<span className="inline-block w-3 h-3 rounded-sm bg-red-200 dark:bg-red-900/40 ring-1 ring-white dark:ring-gray-900" />
<span className="text-red-700 dark:text-red-300 flex items-center gap-1"><Sprout className="w-3.5 h-3.5" /> {t('matrix.legend_level_1')}</span>
</span>
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-gray-800/40 border border-gray-200/40 dark:border-gray-700/30">
<span className="inline-block w-3 h-3 rounded-sm bg-amber-200 dark:bg-amber-900/40 ring-1 ring-white dark:ring-gray-900" />
<span className="text-amber-700 dark:text-amber-300 flex items-center gap-1"><Leaf className="w-3.5 h-3.5" /> {t('matrix.legend_level_2')}</span>
</span>
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-gray-800/40 border border-gray-200/40 dark:border-gray-700/30">
<span className="inline-block w-3 h-3 rounded-sm bg-emerald-200 dark:bg-emerald-900/40 ring-1 ring-white dark:ring-gray-900" />
<span className="text-emerald-700 dark:text-emerald-300 flex items-center gap-1"><TreePine className="w-3.5 h-3.5" /> {t('matrix.legend_level_3')}</span>
</span>
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md bg-white/60 dark:bg-gray-800/40 border border-gray-200/40 dark:border-gray-700/30">
<span className="inline-block w-3 h-3 rounded-sm bg-teal-200 dark:bg-teal-900/40 ring-1 ring-white dark:ring-gray-900" />
<span className="text-teal-700 dark:text-teal-300 flex items-center gap-1"><Trees className="w-3.5 h-3.5" /> {t('matrix.legend_level_4')}</span>
</span>
<span className="ml-auto text-[10px] text-gray-500 dark:text-gray-400 italic">
{t('polish.matrix_click_hint')}
</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Detail dialog */}
<Dialog open={!!detail} onOpenChange={(open) => !open && setDetail(null)}>
<DialogContent className="max-w-2xl rounded-2xl border-emerald-200/60 dark:border-emerald-900/40">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-emerald-700 dark:text-emerald-300">
<CheckCircle2 className="h-5 w-5" />
{detail?.competencyCode} — {detail?.studentName}
</DialogTitle>
<DialogDescription className="text-xs text-gray-500 dark:text-gray-400">
{detail?.competencyTitle} · {t('matrix.entries_for')} {detail?.studentName}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 max-h-[60vh] overflow-y-auto scrollbar-education">
{detailLoading ? (
<Skeleton className="h-24 rounded-lg" />
) : detail && detail.entries.length > 0 ? (
<div className="relative">
{/* Vertical timeline line */}
<div className="absolute left-3 top-2 bottom-2 w-0.5 bg-gradient-to-b from-emerald-300 via-teal-300 to-amber-300 dark:from-emerald-700 dark:via-teal-700 dark:to-amber-700" />
{detail.entries.map((e, i) => (
<motion.div
key={e.id}
initial={{ opacity: 0, x: -6 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.05 }}
className="relative pl-8 pb-2 group"
>
<div className={`absolute left-2.5 top-3 w-3 h-3 rounded-full ring-2 ring-white dark:ring-gray-900 ${
e.masteryLevelValue <= 1 ? 'bg-red-500'
: e.masteryLevelValue <= 2 ? 'bg-amber-500'
: e.masteryLevelValue <= 3 ? 'bg-emerald-500'
: 'bg-teal-500'
}`} style={{ zIndex: 1 }} />
<div className="p-3 rounded-lg bg-gradient-to-r from-emerald-50/50 to-transparent dark:from-emerald-900/15 dark:to-transparent border-l-2 border-emerald-300/50 dark:border-emerald-700/40 hover:shadow-sm transition-shadow">
<div className="flex items-center gap-2 flex-wrap">
<Badge className={`text-xs ${masteryCellColorBadge(e.masteryLevelValue)}`}>
{e.masteryLevelValue <= 1 ? <Sprout className="w-3 h-3 inline text-red-500" /> : e.masteryLevelValue <= 2 ? <Leaf className="w-3 h-3 inline text-amber-500" /> : e.masteryLevelValue <= 3 ? <TreePine className="w-3 h-3 inline text-emerald-500" /> : <Trees className="w-3 h-3 inline text-teal-500" />} {t('analytics.level')} {e.masteryLevelValue}
</Badge>
<span className="text-xs text-gray-500 dark:text-gray-400 inline-flex items-center gap-1">
<Calendar className="h-3 w-3 text-emerald-400" />
{new Date(e.date).toLocaleDateString()}
</span>
<span className="text-xs text-emerald-600/70 dark:text-emerald-400/50">
— {e.teacher.firstName} {e.teacher.lastName}
</span>
</div>
{e.note && (
<p className="text-xs italic text-gray-700 dark:text-gray-300 mt-1.5 bg-amber-50/40 dark:bg-amber-900/5 px-2 py-1 rounded">"{e.note}"</p>
)}
</div>
</motion.div>
))}
</div>
) : (
<div className="text-center py-8">
<XCircle className="h-8 w-8 text-gray-400 dark:text-gray-500 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">{t('matrix.no_entries_for')}</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
function masteryCellColorBadge(level: number): string {
if (level <= 1) return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300';
if (level <= 2) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300';
if (level <= 3) return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300';
return 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300';
}