5 SDD batches archived: - Batch 1: UI Polish (10 features, 14 tasks) - Batch 2: Study System (8 features, 23 tasks) - Batch 3: Infrastructure (5 features, 22 tasks) - Batch 4: AI Advanced (5 features, 30 tasks) — RAG with @xenova/transformers - Batch 5: Core Features (5 features, 19 tasks) 37 bugs fixed from comprehensive code review (11 CRITICAL, 12 HIGH, 14 MEDIUM/LOW): - SSE streaming now works (event.token check) - API keys no longer exposed via GET /api/models - FTS5 injection sanitized - DB backup/restore with admin auth - Buddy mode wired (buddy_meta column) - Exam auto-submit stale closure fixed - CSS variables aligned with design tokens - Progress data corruption fixed - WebSocket protocol auto-detection - Tests infrastructure completed (vitest + node:test)
145 lines
6.0 KiB
JavaScript
145 lines
6.0 KiB
JavaScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { Trash2, Calendar, BookOpen, Tag, TrendingUp } from 'lucide-react';
|
|
|
|
export default function ExamHistory({ exams, onDelete, onRefresh }) {
|
|
const [filterTopic, setFilterTopic] = useState('');
|
|
const [expandedId, setExpandedId] = useState(null);
|
|
|
|
useEffect(() => {
|
|
onRefresh();
|
|
}, [onRefresh]);
|
|
|
|
const allTopics = useMemo(() => {
|
|
const set = new Set();
|
|
for (const e of exams) {
|
|
if (Array.isArray(e.topics)) {
|
|
e.topics.forEach((t) => set.add(t));
|
|
}
|
|
}
|
|
return Array.from(set).sort();
|
|
}, [exams]);
|
|
|
|
const filtered = useMemo(() => {
|
|
if (!filterTopic) return exams;
|
|
return exams.filter((e) => e.topics && e.topics.includes(filterTopic));
|
|
}, [exams, filterTopic]);
|
|
|
|
const formatDate = (iso) => {
|
|
if (!iso) return '';
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString();
|
|
};
|
|
|
|
return (
|
|
<div style={{ padding: '24px', maxWidth: 900, margin: '0 auto' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
|
<h2 style={{ fontSize: 20, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<BookOpen size={22} /> Historial de Exámenes
|
|
</h2>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<select
|
|
value={filterTopic}
|
|
onChange={(e) => setFilterTopic(e.target.value)}
|
|
style={{
|
|
padding: '8px 12px',
|
|
background: 'var(--bg-elevated)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-sm)',
|
|
color: 'var(--text-primary)',
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
<option value="">Todos los temas</option>
|
|
{allTopics.map((t) => (
|
|
<option key={t} value={t}>{t}</option>
|
|
))}
|
|
</select>
|
|
{filterTopic && (
|
|
<button className="btn btn-sm btn-secondary" onClick={() => setFilterTopic('')}>
|
|
Limpiar
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{filtered.length === 0 ? (
|
|
<div className="chat-empty-state" style={{ padding: 48 }}>
|
|
<span style={{ fontSize: 32 }}>📝</span>
|
|
<p>{exams.length === 0 ? 'Aún no has realizado exámenes.' : 'Ningún examen coincide con el filtro.'}</p>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
{filtered.map((exam) => {
|
|
const isExpanded = expandedId === exam.id;
|
|
return (
|
|
<div
|
|
key={exam.id}
|
|
style={{
|
|
background: 'var(--bg-elevated)',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: 'var(--radius-md)',
|
|
padding: '14px 18px',
|
|
cursor: 'pointer',
|
|
transition: 'all var(--transition-fast)',
|
|
}}
|
|
onClick={() => setExpandedId(isExpanded ? null : exam.id)}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<div
|
|
style={{
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: '50%',
|
|
background: exam.score >= 80 ? 'rgba(52,211,153,0.12)' : exam.score >= 50 ? 'rgba(251,191,36,0.12)' : 'rgba(248,113,113,0.12)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: exam.score >= 80 ? 'var(--accent-green)' : exam.score >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)',
|
|
fontWeight: 700,
|
|
fontSize: 13,
|
|
}}
|
|
>
|
|
<TrendingUp size={18} />
|
|
</div>
|
|
<div>
|
|
<div style={{ fontWeight: 600, fontSize: 14 }}>{exam.title}</div>
|
|
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', display: 'flex', alignItems: 'center', gap: 6, marginTop: 2 }}>
|
|
<Calendar size={10} /> {formatDate(exam.taken_at)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
|
|
<div style={{ fontWeight: 700, fontSize: 16, color: exam.score >= 80 ? 'var(--accent-green)' : exam.score >= 50 ? 'var(--accent-amber)' : 'var(--accent-coral)' }}>
|
|
{exam.score}%
|
|
</div>
|
|
<button
|
|
className="icon-btn"
|
|
onClick={(e) => { e.stopPropagation(); onDelete(exam.id); }}
|
|
title="Eliminar"
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{isExpanded && (
|
|
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
|
|
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 8 }}>Temas:</div>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
{Array.isArray(exam.topics) && exam.topics.map((t) => (
|
|
<span key={t} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, background: 'var(--bg-surface)', padding: '4px 10px', borderRadius: 'var(--radius-pill)', fontSize: 12, border: '1px solid var(--border)' }}>
|
|
<Tag size={10} /> {t}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|