Files
learning-platform/src/components/admin/TestManager.jsx
RaymondVerhoef e223836d7d
Some checks failed
On Push to Main / test (push) Has been cancelled
On Push to Main / publish (push) Has been cancelled
On Push to Main / deploy-dev (push) Has been cancelled
feat: add Leren learning page for content generation and feedback, and create TestManager admin component
2026-05-14 22:21:39 +02:00

208 lines
7.6 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import { RefreshCw, Trash2, CheckCircle, Loader, AlertCircle, HelpCircle, ArrowLeft, Eye, ChevronDown, ChevronUp } from 'lucide-react';
import { forceGenerateTopicQuestions, getTopicQuestionBank, deleteQuestion } from '../../lib/testService';
import * as db from '../../lib/db';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Tag from '../ui/Tag';
import { motion, AnimatePresence } from 'framer-motion';
const TestManager = () => {
const [topics, setTopics] = useState([]);
const [questionCounts, setQuestionCounts] = useState({});
const [selectedTopic, setSelectedTopic] = useState(null);
const [questions, setQuestions] = useState([]);
const [loadingTopicId, setLoadingTopicId] = useState(null);
const [error, setError] = useState(null);
const loadData = async () => {
const allTopics = await db.getTopics();
setTopics(allTopics);
const counts = {};
await Promise.all(allTopics.map(async t => {
const bank = await getTopicQuestionBank(t.id);
counts[t.id] = bank.length;
}));
setQuestionCounts(counts);
if (selectedTopic) {
setQuestions(await getTopicQuestionBank(selectedTopic.id));
}
};
useEffect(() => {
loadData();
}, [selectedTopic]);
const handleGenerate = async (topic, count = 10) => {
setLoadingTopicId(topic.id);
setError(null);
try {
await forceGenerateTopicQuestions(topic, count);
loadData(); // refresh list
} catch (e) {
setError(e.message);
} finally {
setLoadingTopicId(null);
}
};
const handleDelete = (topicId, questionId) => {
deleteQuestion(topicId, questionId);
loadData();
};
// ── Detail view ──
if (selectedTopic) {
return (
<div className="animate-in fade-in duration-200">
{/* Header */}
<div className="flex items-start justify-between mb-6 gap-4 flex-wrap">
<div>
<button
onClick={() => setSelectedTopic(null)}
className="flex items-center gap-2 text-sm text-fg-muted hover:text-teal transition-colors mb-3"
>
<ArrowLeft size={16} /> Back to Topics
</button>
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-2xl font-bold">{selectedTopic.label}</h2>
<Tag variant="accent" className="text-xs">{questions.length} questions</Tag>
</div>
<p className="text-fg-muted text-sm mt-1">{selectedTopic.description}</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => handleGenerate(selectedTopic, 5)}
disabled={loadingTopicId === selectedTopic.id}
>
{loadingTopicId === selectedTopic.id
? <Loader size={16} className="mr-2 animate-spin" />
: <RefreshCw size={16} className="mr-2" />}
Generate 5 More
</Button>
</div>
</div>
{error && (
<div className="mb-6 p-3 rounded-[var(--r-sm)] bg-red-50 text-red-800 text-sm flex items-center gap-2">
<AlertCircle size={16} /> {error}
</div>
)}
{/* Questions List */}
<div className="space-y-4">
{questions.length === 0 ? (
<Card className="text-center py-12 text-fg-muted border-dashed border-2">
<p>No questions generated for this topic yet.</p>
<Button className="mt-4" onClick={() => handleGenerate(selectedTopic, 10)} disabled={loadingTopicId === selectedTopic.id}>
Generate 10 Questions
</Button>
</Card>
) : (
questions.map((q, i) => (
<Card key={q.id} className="border border-bg-warm relative group">
<button
onClick={() => handleDelete(selectedTopic.id, q.id)}
className="absolute top-4 right-4 p-2 text-fg-muted hover:text-red-500 hover:bg-red-50 rounded-[var(--r-sm)] opacity-0 group-hover:opacity-100 transition-all"
title="Delete question"
>
<Trash2 size={16} />
</button>
<div className="flex items-start gap-3 mb-4">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-teal/10 text-teal flex items-center justify-center text-xs font-bold mt-0.5">
{i + 1}
</div>
<div>
<h4 className="font-semibold">{q.question}</h4>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 ml-9 mb-4">
{q.options.map((opt, oi) => (
<div
key={oi}
className={`px-3 py-2 rounded-[var(--r-sm)] text-sm border ${
oi === q.correctIndex
? 'border-teal bg-teal/5 text-teal font-medium'
: 'border-bg-warm text-fg-muted'
}`}
>
{oi === q.correctIndex && <CheckCircle size={14} className="inline mr-2 -mt-0.5" />}
{opt}
</div>
))}
</div>
<p className="text-sm text-fg-muted ml-9 italic border-l-2 border-teal/30 pl-3">
{q.explanation}
</p>
</Card>
))
)}
</div>
</div>
);
}
// ── List view ──
return (
<div className="space-y-3">
{topics.length === 0 && (
<div className="text-center py-16 text-fg-muted">
<HelpCircle size={48} className="mx-auto mb-4 text-teal/30" />
<p className="font-medium">No topics available.</p>
</div>
)}
{error && !selectedTopic && (
<div className="mb-4 p-3 rounded-[var(--r-sm)] bg-red-50 text-red-800 text-sm flex items-center gap-2">
<AlertCircle size={16} /> {error}
</div>
)}
{topics.map(topic => {
const count = questionCounts[topic.id] || 0;
const isLoading = loadingTopicId === topic.id;
return (
<Card key={topic.id} className="border border-bg-warm flex items-center justify-between">
<div className="flex-1 min-w-0 pr-4">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold truncate">{topic.label}</h3>
<Tag variant={count > 0 ? 'success' : 'dark'} className="text-xs">
{count} {count === 1 ? 'question' : 'questions'}
</Tag>
</div>
<p className="text-sm text-fg-muted truncate">{topic.description}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{count === 0 ? (
<Button
variant="outline"
onClick={() => handleGenerate(topic)}
disabled={isLoading}
className="whitespace-nowrap"
>
{isLoading ? <Loader size={16} className="animate-spin mr-2" /> : <RefreshCw size={16} className="mr-2" />}
Generate
</Button>
) : (
<Button onClick={() => setSelectedTopic(topic)} className="flex items-center gap-2">
<Eye size={16} /> Review
</Button>
)}
</div>
</Card>
);
})}
</div>
);
};
export default TestManager;