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:
Mustafa Cetin committed 2026-07-29 05:46:10 +00:00
commit 0c1f739989
156 files changed
+51090

No files matched your search

+882
View File
@@ -0,0 +1,882 @@
'use client';
import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { motion } from 'framer-motion';
import {
Target,
RefreshCw,
CheckCircle2,
AlertCircle,
XCircle,
BookOpen,
Users,
Layers,
TrendingUp,
Clock,
Search,
ChevronRight,
Sparkles,
GraduationCap,
AlertTriangle,
PartyPopper,
Sprout,
Leaf,
TreePine,
Trees,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
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 { useAppStore } from '@/lib/store';
import { t } from '@/lib/i18n';
import { toast } from 'sonner';
import {
fetchClasses,
fetchSubjects,
fetchCurriculumCoverage,
type ClassGroup,
type Subject,
type CurriculumCoverage,
type CurriculumCoverageCompetency,
} from '@/lib/api';
const STATUS_META: Record<
'covered' | 'partial' | 'untouched',
{ icon: React.ElementType; bg: string; text: string; border: string }
> = {
covered: {
icon: CheckCircle2,
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
text: 'text-emerald-700 dark:text-emerald-300',
border: 'border-emerald-200 dark:border-emerald-800',
},
partial: {
icon: AlertTriangle,
bg: 'bg-amber-50 dark:bg-amber-900/20',
text: 'text-amber-700 dark:text-amber-300',
border: 'border-amber-200 dark:border-amber-800',
},
untouched: {
icon: XCircle,
bg: 'bg-rose-50 dark:bg-rose-900/20',
text: 'text-rose-700 dark:text-rose-300',
border: 'border-rose-200 dark:border-rose-800',
},
};
function formatDate(iso: string | null, locale: string): string {
if (!iso) return '';
try {
const d = new Date(iso);
return d.toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
} catch {
return iso;
}
}
function MasteryIcon({ level }: { level: number | null }) {
if (level === null || level === undefined) return <span className="text-lg">·</span>;
if (level <= 1) return <Sprout className="w-5 h-5" />;
if (level === 2) return <Leaf className="w-5 h-5" />;
if (level === 3) return <TreePine className="w-5 h-5" />;
return <Trees className="w-5 h-5" />;
}
export default function CurriculumCoverageView() {
const currentUser = useAppStore((s) => s.currentUser);
const schoolYearId = useAppStore((s) => s.schoolYearId);
const storeClassId = useAppStore((s) => s.currentClassId);
const setCurrentView = useAppStore((s) => s.setCurrentView);
const setCurrentClass = useAppStore((s) => s.setCurrentClass);
const locale = currentUser?.locale ?? 'de';
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<CurriculumCoverage | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filter/sort state for matrix
const [statusFilter, setStatusFilter] = useState<'all' | 'covered' | 'partial' | 'untouched'>('all');
const [sortBy, setSortBy] = useState<'category' | 'status' | 'recent'>('category');
const [searchQuery, setSearchQuery] = useState('');
const [detailComp, setDetailComp] = useState<CurriculumCoverageCompetency | null>(null);
// Load classes + subjects
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 loadCoverage = useCallback(async () => {
if (!selectedClassId) {
setData(null);
return;
}
setLoading(true);
setError(null);
try {
const d = await fetchCurriculumCoverage({
classGroupId: selectedClassId,
subjectId: selectedSubjectId || undefined,
});
setData(d);
} catch (err) {
const msg = err instanceof Error ? err.message : t('coverage.error');
setError(msg);
} finally {
setLoading(false);
}
}, [selectedClassId, selectedSubjectId]);
useEffect(() => {
loadCoverage();
}, [loadCoverage]);
// Sync selected class back to store for cross-view consistency
useEffect(() => {
if (selectedClassId && selectedClassId !== storeClassId) {
setCurrentClass(selectedClassId);
}
}, [selectedClassId, storeClassId, setCurrentClass]);
// Filtered + sorted competency list
const filteredCompetencies = useMemo(() => {
if (!data) return [];
let list = data.byCompetency.slice();
if (statusFilter !== 'all') {
list = list.filter((c) => c.status === statusFilter);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase().trim();
list = list.filter(
(c) => c.code.toLowerCase().includes(q) || c.title.toLowerCase().includes(q)
);
}
if (sortBy === 'category') {
list.sort((a, b) => a.categoryName.localeCompare(b.categoryName) || a.code.localeCompare(b.code));
} else if (sortBy === 'status') {
const order = { untouched: 0, partial: 1, covered: 2 };
list.sort((a, b) => order[a.status] - order[b.status] || a.categoryName.localeCompare(b.categoryName));
} else if (sortBy === 'recent') {
list.sort((a, b) => {
const aT = a.lastAssessedDate ? new Date(a.lastAssessedDate).getTime() : 0;
const bT = b.lastAssessedDate ? new Date(b.lastAssessedDate).getTime() : 0;
return bT - aT;
});
}
return list;
}, [data, statusFilter, sortBy, searchQuery]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-lg shadow-emerald-500/20">
<Target className="h-5 w-5" />
</div>
<div>
<h2 className="text-2xl font-bold tracking-tight bg-gradient-to-r from-emerald-600 to-teal-600 bg-clip-text text-transparent">
{t('coverage.title')}
</h2>
<p className="text-sm text-muted-foreground">{t('coverage.subtitle')}</p>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={loadCoverage} disabled={loading}>
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
{t('action.refresh')}
</Button>
</div>
</div>
{/* Filter row */}
<Card className="border-emerald-100 dark:border-emerald-900/30">
<CardContent className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<div className="flex-1 space-y-1.5">
<label className="text-[10px] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t('label.class')}
</label>
<Select value={selectedClassId} onValueChange={setSelectedClassId}>
<SelectTrigger className="w-full rounded-xl border-emerald-300 dark:border-emerald-700 bg-emerald-50/30 dark:bg-emerald-900/10 ring-1 ring-emerald-200/60 dark:ring-emerald-800/40 hover:border-emerald-400 dark:hover:border-emerald-600 [&_svg]:opacity-100 [&_svg]:text-emerald-500">
<SelectValue placeholder={t('coverage.select_class')} />
</SelectTrigger>
<SelectContent>
{classes.map((c) => (
<SelectItem key={c.id} value={c.id}>
🏫 {c.name} · {c.schoolType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 space-y-1.5">
<label className="text-[10px] font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
{t('label.subject')}
</label>
<Select
value={selectedSubjectId || '__all__'}
onValueChange={(v) => setSelectedSubjectId(v === '__all__' ? '' : v)}
>
<SelectTrigger className="w-full rounded-xl border-emerald-300 dark:border-emerald-700 bg-emerald-50/30 dark:bg-emerald-900/10 ring-1 ring-emerald-200/60 dark:ring-emerald-800/40 hover:border-emerald-400 dark:hover:border-emerald-600 [&_svg]:opacity-100 [&_svg]:text-emerald-500">
<SelectValue placeholder={t('coverage.select_subject')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{t('coverage.select_subject')}</SelectItem>
{subjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* Main content */}
{!selectedClassId ? (
<EmptyClassState />
) : loading ? (
<LoadingState />
) : error ? (
<ErrorState message={error} onRetry={loadCoverage} />
) : !data ? null : !data.hasAssignments ? (
<NoAssignmentsState onGoToGrid={() => setCurrentView('competencies')} />
) : (
<div className="space-y-6">
{/* KPI tiles */}
<KpiTiles data={data} />
{/* Section 1: by category */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Layers className="h-5 w-5 text-emerald-600" />
{t('coverage.section.by_category')}
</CardTitle>
<CardDescription>{t('coverage.section.by_category_desc')}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{data.byCategory.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">{t('coverage.no_data')}</p>
) : (
data.byCategory.map((cat) => (
<CategoryBar key={cat.categoryId} cat={cat} />
))
)}
</CardContent>
</Card>
{/* Section 2: matrix */}
<Card>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<BookOpen className="h-5 w-5 text-teal-600" />
{t('coverage.section.matrix')}
</CardTitle>
<CardDescription>{t('coverage.section.matrix_desc')}</CardDescription>
</div>
</div>
{/* Filters */}
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="flex flex-wrap gap-1">
{(['all', 'covered', 'partial', 'untouched'] as const).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-all ${
statusFilter === f
? 'bg-emerald-500 text-white shadow-md shadow-emerald-500/30'
: 'bg-muted hover:bg-emerald-100 dark:hover:bg-emerald-900/20 text-muted-foreground'
}`}
>
{t(`coverage.filter.${f}`)}
</button>
))}
</div>
<div className="flex flex-1 gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('coverage.search_placeholder')}
className="pl-8 rounded-xl"
/>
</div>
<Select value={sortBy} onValueChange={(v) => setSortBy(v as 'category' | 'status' | 'recent')}>
<SelectTrigger className="w-[160px] rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="category">{t('coverage.sort.by_category')}</SelectItem>
<SelectItem value="status">{t('coverage.sort.by_status')}</SelectItem>
<SelectItem value="recent">{t('coverage.sort.by_recent')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
{filteredCompetencies.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">{t('coverage.no_data')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{filteredCompetencies.map((comp, i) => (
<motion.div
key={comp.competencyId}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: Math.min(i * 0.03, 0.4) }}
>
<CompetencyCard
comp={comp}
studentsCount={data.totals.studentsCount}
locale={locale}
onClick={() => setDetailComp(comp)}
/>
</motion.div>
))}
</div>
)}
</CardContent>
</Card>
{/* Section 3 + 4: action needed + recent (2-col on desktop) */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Action needed */}
<Card className="border-rose-100 dark:border-rose-900/30">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<AlertCircle className="h-5 w-5 text-rose-600" />
{t('coverage.section.action_needed')}
</CardTitle>
<CardDescription>{t('coverage.section.action_needed_desc')}</CardDescription>
</CardHeader>
<CardContent>
{data.notAssessedList.length === 0 ? (
<div className="text-center py-8 space-y-2">
<PartyPopper className="w-10 h-10 text-emerald-500 dark:text-emerald-400 mx-auto" />
<p className="font-semibold text-emerald-700 dark:text-emerald-300">
{t('coverage.empty_action')}
</p>
<p className="text-xs text-muted-foreground">{t('coverage.empty_action_desc')}</p>
</div>
) : (
<ul className="space-y-2 max-h-96 overflow-y-auto pr-1">
{data.notAssessedList.slice(0, 10).map((c) => {
const meta = STATUS_META[c.status];
return (
<li
key={c.competencyId}
className="flex items-center gap-2 p-2 rounded-lg hover:bg-muted/60 transition-colors"
>
<span className={`flex h-7 w-7 items-center justify-center rounded-md text-xs font-bold ${meta.bg} ${meta.text}`}>
<meta.icon className="w-3.5 h-3.5" />
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<code className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{c.code}
</code>
<span className="text-xs truncate">{c.title}</span>
</div>
<div className="text-[10px] text-muted-foreground">
{c.categoryName} · {c.studentsAssessed}/{c.studentsCount} {t('coverage.assessed_count').toLowerCase()}
</div>
</div>
<Button
size="sm"
variant="ghost"
className="h-7 px-2 text-emerald-700 hover:text-emerald-800 hover:bg-emerald-50 dark:text-emerald-300 dark:hover:bg-emerald-900/20"
onClick={() => {
setCurrentClass(selectedClassId);
setCurrentView('progress');
}}
>
{t('coverage.record_progress')}
<ChevronRight className="h-3 w-3 ml-0.5" />
</Button>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
{/* Recent */}
<Card className="border-teal-100 dark:border-teal-900/30">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-lg">
<Clock className="h-5 w-5 text-teal-600" />
{t('coverage.section.recent')}
</CardTitle>
<CardDescription>{t('coverage.section.recent_desc')}</CardDescription>
</CardHeader>
<CardContent>
{data.recentAssessed.length === 0 ? (
<div className="text-center py-8 space-y-2">
<div className="text-3xl opacity-60">📭</div>
<p className="text-sm font-medium">{t('coverage.empty_recent')}</p>
<p className="text-xs text-muted-foreground">{t('coverage.empty_recent_desc')}</p>
</div>
) : (
<ul className="space-y-2">
{data.recentAssessed.map((r, idx) => (
<motion.li
key={r.competencyId}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.25, delay: idx * 0.05 }}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/60 transition-colors"
>
<MasteryIcon level={r.masteryLevelValue} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<code className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{r.code}
</code>
<span className="text-sm truncate">{r.title}</span>
</div>
<div className="text-[10px] text-muted-foreground">
{r.categoryName} · {formatDate(r.date, locale)}
</div>
</div>
</motion.li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
)}
{/* Detail dialog */}
<Dialog open={!!detailComp} onOpenChange={(v) => !v && setDetailComp(null)}>
<DialogContent className="sm:max-w-md">
{detailComp && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<span className={`flex h-7 w-7 items-center justify-center rounded-md ${STATUS_META[detailComp.status].bg} ${STATUS_META[detailComp.status].text}`}>
{(() => { const StatusIcon = STATUS_META[detailComp.status].icon; return <StatusIcon className="w-3.5 h-3.5" />; })()}
</span>
<code className="text-xs font-mono text-muted-foreground">{detailComp.code}</code>
</DialogTitle>
<DialogDescription className="text-base font-medium text-foreground">
{detailComp.title}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('coverage.assessed_count')}</span>
<Badge className={`${STATUS_META[detailComp.status].bg} ${STATUS_META[detailComp.status].text} ${STATUS_META[detailComp.status].border} border`}>
{detailComp.studentsAssessed} / {data?.totals.studentsCount ?? 0}
</Badge>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('coverage.progress_count')}</span>
<span className="font-semibold">{detailComp.progressCount}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('coverage.assessments_count')}</span>
<span className="font-semibold">{detailComp.assessmentCount}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('coverage.last_assessed')}</span>
<span className="font-semibold">
{detailComp.lastAssessedDate ? formatDate(detailComp.lastAssessedDate, locale) : t('coverage.never_assessed')}
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('label.category')}</span>
<Badge
variant="outline"
style={{
borderColor: detailComp.categoryColor,
color: detailComp.categoryColor,
backgroundColor: `${detailComp.categoryColor}15`,
}}
>
{detailComp.categoryName}
</Badge>
</div>
<Button
className="w-full mt-2 bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700"
onClick={() => {
if (selectedClassId) setCurrentClass(selectedClassId);
setCurrentView('progress');
}}
>
{t('coverage.record_progress')}
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
);
}
/* ── Sub-components ───────────────────────────────────────────────── */
function KpiTiles({ data }: { data: CurriculumCoverage }) {
const tiles = [
{
label: t('coverage.kpi.total'),
value: data.totals.competencies,
icon: Layers,
gradient: 'from-emerald-500 to-teal-600',
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
},
{
label: t('coverage.kpi.assessed'),
value: `${data.totals.assessed} (${data.totals.coveragePercent}%)`,
icon: CheckCircle2,
gradient: 'from-teal-500 to-emerald-600',
bg: 'bg-teal-50 dark:bg-teal-900/20',
ring: true,
coveragePercent: data.totals.coveragePercent,
},
{
label: t('coverage.kpi.not_assessed'),
value: `${data.totals.notAssessed} (${100 - data.totals.coveragePercent}%)`,
icon: AlertCircle,
gradient: 'from-rose-500 to-amber-500',
bg: 'bg-rose-50 dark:bg-rose-900/20',
},
{
label: t('coverage.kpi.students'),
value: data.totals.studentsCount,
icon: Users,
gradient: 'from-amber-500 to-violet-500',
bg: 'bg-amber-50 dark:bg-amber-900/20',
},
];
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{tiles.map((tile, i) => (
<motion.div
key={tile.label}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: i * 0.05 }}
>
<Card className={`${tile.bg} border-0 shadow-sm`}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1 leading-tight">
{tile.label}
</p>
<p className="text-2xl font-bold tracking-tight">{tile.value}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{tile.ring && (
<svg width="40" height="40" viewBox="0 0 40 40" className="shrink-0">
<circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="currentColor"
strokeWidth="3"
className="text-gray-200 dark:text-gray-700"
/>
<circle
cx="20"
cy="20"
r="16"
fill="none"
stroke="#10b981"
strokeWidth="3"
strokeDasharray={`${(tile.coveragePercent! / 100) * 2 * Math.PI * 16} ${2 * Math.PI * 16}`}
strokeLinecap="round"
transform="rotate(-90 20 20)"
/>
<text
x="20"
y="20"
textAnchor="middle"
dominantBaseline="central"
className="text-[9px] font-bold fill-emerald-700 dark:fill-emerald-300"
>
{tile.coveragePercent}%
</text>
</svg>
)}
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br ${tile.gradient} text-white shadow-md shrink-0`}>
<tile.icon className="h-4 w-4" />
</div>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div>
);
}
function CategoryBar({
cat,
}: {
cat: CurriculumCoverage['byCategory'][number];
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 min-w-0">
<span
className="h-2.5 w-2.5 rounded-full shrink-0"
style={{ backgroundColor: cat.categoryColor }}
/>
<span className="font-medium truncate">{cat.categoryName}</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0">
<span>{cat.assessed}/{cat.total}</span>
<span className="font-semibold" style={{ color: cat.categoryColor }}>
{cat.coveragePercent}%
</span>
</div>
</div>
<div className="h-2.5 rounded-full bg-muted overflow-hidden flex">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${cat.coveragePercent}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="h-full rounded-full"
style={{ backgroundColor: cat.categoryColor }}
/>
<motion.div
initial={{ width: '100%' }}
animate={{ width: `${100 - cat.coveragePercent}%` }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="h-full bg-muted-foreground/15"
/>
</div>
</div>
);
}
function CompetencyCard({
comp,
studentsCount,
locale,
onClick,
}: {
comp: CurriculumCoverageCompetency;
studentsCount: number;
locale: string;
onClick: () => void;
}) {
const meta = STATUS_META[comp.status];
return (
<button
onClick={onClick}
className="w-full text-left p-3 rounded-xl border border-border/60 hover:border-emerald-300 dark:hover:border-emerald-700 hover:shadow-md transition-all bg-card"
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 mb-1">
<code className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{comp.code}
</code>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${meta.bg} ${meta.text} border ${meta.border}`}>
<meta.icon className="w-3 h-3 inline mr-0.5" /> {t(`coverage.status.${comp.status}`)}
</span>
</div>
<p className="text-sm font-medium leading-tight line-clamp-2">{comp.title}</p>
</div>
</div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<div className="flex items-center gap-2">
<span
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md"
style={{ backgroundColor: `${comp.categoryColor}20`, color: comp.categoryColor }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: comp.categoryColor }} />
{comp.categoryName}
</span>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{comp.studentsAssessed}/{studentsCount}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{comp.lastAssessedDate ? formatDate(comp.lastAssessedDate, locale) : '—'}
</span>
</div>
</div>
</button>
);
}
function EmptyClassState() {
return (
<Card className="border-dashed border-emerald-200 dark:border-emerald-900/40">
<CardContent className="py-12">
<div className="space-y-6">
<div className="text-center space-y-3">
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500 to-teal-600 text-white shadow-xl shadow-emerald-500/20">
<GraduationCap className="h-8 w-8" />
</div>
<div>
<h3 className="text-lg font-semibold">{t('coverage.select_class')}</h3>
</div>
</div>
<div className="space-y-3">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-1">{t('coverage.guide.subtitle')}</p>
<h4 className="text-base font-semibold flex items-center justify-center gap-1.5">
<Sparkles className="h-4 w-4 text-amber-500" />
{t('coverage.guide.title')}
</h4>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{[
{ num: 1, icon: Users, title: t('coverage.guide.step1_title'), desc: t('coverage.guide.step1_desc'), gradient: 'from-emerald-500 to-teal-600' },
{ num: 2, icon: BookOpen, title: t('coverage.guide.step2_title'), desc: t('coverage.guide.step2_desc'), gradient: 'from-teal-500 to-emerald-600' },
{ num: 3, icon: Target, title: t('coverage.guide.step3_title'), desc: t('coverage.guide.step3_desc'), gradient: 'from-amber-500 to-violet-500' },
].map((step) => (
<div
key={step.num}
className="relative p-4 rounded-xl border border-emerald-100 dark:border-emerald-900/30 bg-gradient-to-br from-emerald-50/50 to-teal-50/50 dark:from-emerald-900/10 dark:to-teal-900/10"
>
<div className="absolute top-3 right-3 flex h-7 w-7 items-center justify-center rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 text-white text-xs font-bold shadow-md">
{step.num}
</div>
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-gradient-to-br ${step.gradient} text-white shadow-md mb-3`}>
<step.icon className="h-4 w-4" />
</div>
<h5 className="font-semibold text-sm mb-1">{step.title}</h5>
<p className="text-xs text-muted-foreground leading-relaxed">{step.desc}</p>
</div>
))}
</div>
</div>
</div>
</CardContent>
</Card>
);
}
function NoAssignmentsState({ onGoToGrid }: { onGoToGrid: () => void }) {
return (
<Card className="border-dashed border-amber-200 dark:border-amber-900/40">
<CardContent className="py-12 text-center space-y-4">
<div className="inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-400 to-amber-600 text-white shadow-xl">
<BookOpen className="h-8 w-8" />
</div>
<div className="space-y-1">
<h3 className="text-lg font-semibold">{t('coverage.no_assignments')}</h3>
<p className="text-sm text-muted-foreground max-w-md mx-auto">
{t('coverage.no_assignments_desc')}
</p>
</div>
<Button
onClick={onGoToGrid}
className="bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-600 hover:to-amber-700"
>
{t('coverage.go_to_grid')}
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</CardContent>
</Card>
);
}
function LoadingState() {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
<Card>
<CardContent className="p-6 space-y-3">
{[1, 2, 3, 4].map((i) => (
<Skeleton key={i} className="h-12 rounded-lg" />
))}
</CardContent>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Skeleton key={i} className="h-24 rounded-xl" />
))}
</div>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<Card className="border-rose-200 dark:border-rose-900/40">
<CardContent className="py-12 text-center space-y-4">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl bg-rose-100 dark:bg-rose-900/30 text-rose-600 dark:text-rose-400">
<XCircle className="h-7 w-7" />
</div>
<div>
<h3 className="font-semibold">{t('coverage.error')}</h3>
<p className="text-sm text-muted-foreground mt-1">{message}</p>
</div>
<Button variant="outline" onClick={onRetry}>
<RefreshCw className="h-4 w-4 mr-1" />
{t('action.refresh')}
</Button>
</CardContent>
</Card>
);
}