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
This commit is contained in:
commit
0c1f739989
156 files changed
+51090
No files matched your search
@@ -0,0 +1,780 @@
|
||||
'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';
|
||||
}
|
||||
Reference in new issue
Block a user