feat: token count implement

This commit is contained in:
2026-03-17 12:07:56 -03:00
parent 41279585f6
commit be699ad6ab
44 changed files with 9032 additions and 167 deletions
@@ -364,6 +364,8 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
return (
<QuizPlayer
id={block.id}
lessonId={params.lessonId}
courseId={params.id}
title={block.title}
quizData={block.quiz_data || { questions: [] }}
allowRetry={lesson.allow_retry}
@@ -373,25 +375,32 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les
? (userGrade.metadata.block_attempts as Record<string, number>)[block.id] || 0
: 0
}
onAttempt={async () => {
existingGrade={
userGrade?.metadata?.quiz_answers
? {
score: userGrade.score,
answers: userGrade.metadata.quiz_answers[block.id] || userGrade.metadata.quiz_answers,
created_at: userGrade.created_at,
}
: undefined
}
onAttempt={async (score, answers) => {
if (user) {
const currentAttempts = (userGrade?.metadata?.block_attempts as Record<string, number>) || {};
const newAttempts = {
...currentAttempts,
[block.id]: (currentAttempts[block.id] || 0) + 1
};
try {
const res = await lmsApi.submitScore(
// Submit the score for this specific quiz block
const res = await lmsApi.submitLessonScore(
user.id,
params.id,
params.lessonId,
userGrade?.score || 0,
{ ...userGrade?.metadata, block_attempts: newAttempts }
score,
{
quiz_answers: { [block.id]: answers },
quiz_type: block.quiz_data?.test_type || 'quiz',
}
);
setUserGrade(res);
} catch (err) {
console.error("Error al guardar los intentos del bloque", err);
console.error("Error al guardar el puntaje del quiz", err);
}
}
}}
@@ -1,33 +1,65 @@
"use client";
import { useState, useEffect } from "react";
import { lmsApi } from "@/lib/api";
interface QuizQuestion {
id: string;
question: string;
options: string[];
correct: number[];
correct: number | number[];
type?: 'multiple-choice' | 'true-false' | 'multiple-select';
explanation?: string;
points?: number;
}
interface QuizPlayerProps {
id: string;
lessonId: string;
courseId: string;
title?: string;
quizData: {
questions: QuizQuestion[];
instructions?: string;
passing_score?: number;
total_points?: number;
max_attempts?: number;
show_feedback?: boolean;
permanent_history?: boolean;
};
allowRetry?: boolean;
maxAttempts?: number;
initialAttempts?: number;
onAttempt?: () => void;
existingGrade?: {
score: number;
answers: any;
created_at: string;
};
onAttempt?: (score: number, answers: any) => void;
}
export default function QuizPlayer({ id, title, quizData, allowRetry = true, maxAttempts = 0, initialAttempts = 0, onAttempt }: QuizPlayerProps) {
export default function QuizPlayer({
id,
lessonId,
courseId,
title,
quizData,
allowRetry = true,
maxAttempts = 0,
initialAttempts = 0,
existingGrade,
onAttempt
}: QuizPlayerProps) {
const [userAnswers, setUserAnswers] = useState<Record<string, number[]>>({});
const [submitted, setSubmitted] = useState(false);
const [attempts, setAttempts] = useState(initialAttempts || 0);
const [submitting, setSubmitting] = useState(false);
const [score, setScore] = useState<number | null>(null);
const [showHistory, setShowHistory] = useState(false);
const questions = quizData?.questions || [];
const isSingleAttempt = maxAttempts === 1 || quizData.max_attempts === 1;
const hasExistingGrade = existingGrade !== undefined && existingGrade !== null;
// Sync attempts with prop
useEffect(() => {
@@ -36,8 +68,18 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
}
}, [initialAttempts]);
// Load existing grade if available
useEffect(() => {
if (hasExistingGrade && existingGrade?.answers) {
setUserAnswers(existingGrade.answers);
setSubmitted(true);
setScore(existingGrade.score * 100);
}
}, [existingGrade]);
const handleAnswer = (qId: string, optionIndex: number, isMulti: boolean) => {
if (submitted) return;
if (submitted) return; // No changes after submission
setUserAnswers(prev => {
const current = prev[qId] || [];
if (isMulti) {
@@ -51,22 +93,234 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
});
};
const handleValidate = () => {
const calculateScore = () => {
let totalPoints = 0;
let earnedPoints = 0;
questions.forEach(q => {
const points = q.points || 1;
totalPoints += points;
const userAnswer = userAnswers[q.id] || [];
const correctAnswer = Array.isArray(q.correct) ? q.correct : [q.correct];
// Check if all correct answers are selected and no incorrect ones
const allCorrectSelected = correctAnswer.every(idx => userAnswer.includes(idx));
const noIncorrectSelected = userAnswer.every(idx => correctAnswer.includes(idx));
if (allCorrectSelected && noIncorrectSelected) {
earnedPoints += points;
}
});
return totalPoints > 0 ? earnedPoints / totalPoints : 0;
};
const handleValidate = async () => {
if (maxAttempts > 0 && attempts >= maxAttempts) return;
setSubmitted(true);
if (onAttempt) {
onAttempt();
// Check if all questions are answered
const allAnswered = questions.every(q => userAnswers[q.id] && userAnswers[q.id].length > 0);
if (!allAnswered) {
if (!confirm('Hay preguntas sin responder. ¿Estás seguro de que deseas enviar?')) {
return;
}
}
try {
setSubmitting(true);
// Calculate score
const calculatedScore = calculateScore();
const scorePercent = Math.round(calculatedScore * 100);
setScore(scorePercent);
// Prepare answers metadata
const answersMetadata = {
answers: userAnswers,
questions: questions.map(q => ({
id: q.id,
question: q.question,
options: q.options,
correct: q.correct,
explanation: q.explanation,
points: q.points,
})),
submitted_at: new Date().toISOString(),
quiz_type: quizData.test_type || 'quiz',
};
// Submit to LMS API
await lmsApi.submitLessonScore(courseId, lessonId, calculatedScore, answersMetadata);
setSubmitted(true);
setAttempts(prev => prev + 1);
if (onAttempt) {
onAttempt(calculatedScore, answersMetadata);
}
// Show success message
alert(`¡Prueba enviada! Tu puntuación: ${scorePercent}%`);
} catch (error) {
console.error('Error submitting quiz:', error);
alert('Error al enviar la prueba. Por favor, inténtalo de nuevo.');
} finally {
setSubmitting(false);
}
};
const getQuestionScore = (q: QuizQuestion) => {
if (!submitted) return null;
const userAnswer = userAnswers[q.id] || [];
const correctAnswer = Array.isArray(q.correct) ? q.correct : [q.correct];
const allCorrectSelected = correctAnswer.every(idx => userAnswer.includes(idx));
const noIncorrectSelected = userAnswer.every(idx => correctAnswer.includes(idx));
if (allCorrectSelected && noIncorrectSelected) {
return 'correct';
} else if (userAnswer.length === 0) {
return 'unanswered';
} else {
return 'incorrect';
}
};
// If already submitted and single attempt, show read-only view
if (hasExistingGrade && isSingleAttempt) {
return (
<div className="space-y-6 notranslate" id={id} translate="no">
<div className="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-2xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white">
{title || "Prueba Completada"}
</h3>
<div className="text-right">
<div className="text-3xl font-black text-blue-600 dark:text-blue-400">
{existingGrade?.score ? Math.round(existingGrade.score * 100) : 0}%
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{new Date(existingGrade?.created_at || Date.now()).toLocaleDateString()}
</div>
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="w-2 h-2 bg-green-500 rounded-full"></span>
Completado
</span>
<span className="text-gray-500 dark:text-gray-400">
1 intento permitido
</span>
<button
onClick={() => setShowHistory(!showHistory)}
className="text-blue-600 hover:text-blue-700 font-medium"
>
{showHistory ? 'Ocultar detalles' : 'Ver mis respuestas'}
</button>
</div>
</div>
{showHistory && (
<div className="space-y-4">
{questions.map((q, qIdx) => {
const userAnswer = userAnswers[q.id] || [];
const correctAnswer = Array.isArray(q.correct) ? q.correct : [q.correct];
const questionScore = getQuestionScore(q);
return (
<div
key={q.id}
className={`border rounded-xl p-5 ${
questionScore === 'correct'
? 'border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-900/20'
: 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20'
}`}
>
<div className="flex items-start gap-3 mb-3">
<span className={`text-lg font-bold ${
questionScore === 'correct' ? 'text-green-600' : 'text-red-600'
}`}>
{questionScore === 'correct' ? '✓' : '✗'}
</span>
<div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-white mb-3">
{qIdx + 1}. {q.question}
</p>
<div className="space-y-2">
{q.options.map((opt, oIdx) => {
const isSelected = userAnswer.includes(oIdx);
const isCorrect = correctAnswer.includes(oIdx);
let optionClass = "p-3 rounded-lg border text-sm ";
if (isCorrect) {
optionClass += "border-green-500 bg-green-100 dark:bg-green-900/40 text-green-800 dark:text-green-200";
} else if (isSelected && !isCorrect) {
optionClass += "border-red-500 bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200";
} else {
optionClass += "border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 opacity-60";
}
return (
<div key={oIdx} className={optionClass}>
<div className="flex items-center justify-between">
<span>{opt}</span>
{isCorrect && <span className="text-green-600"> Correcta</span>}
{isSelected && !isCorrect && <span className="text-red-600"> Tu respuesta</span>}
</div>
</div>
);
})}
</div>
{q.explanation && (
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1">
📝 Explicación:
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
{q.explanation}
</p>
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
// Normal quiz mode (not yet submitted or allows retry)
return (
<div className="space-y-8 notranslate" id={id} translate="no">
<div className="space-y-2">
<h3 className="text-xl font-bold border-l-4 border-blue-600 dark:border-blue-500 pl-4 py-1 tracking-tight text-gray-900 dark:text-white uppercase tracking-widest text-[10px]">
{title || "Verificación de Conocimientos"}
</h3>
<div className="flex items-center justify-between">
<h3 className="text-xl font-bold border-l-4 border-blue-600 dark:border-blue-500 pl-4 py-1 tracking-tight text-gray-900 dark:text-white uppercase tracking-widest text-[10px]">
{title || "Verificación de Conocimientos"}
</h3>
{isSingleAttempt && (
<span className="px-3 py-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 text-xs font-black uppercase tracking-widest rounded-full border border-red-200 dark:border-red-800">
1 Solo Intento
</span>
)}
</div>
{quizData.instructions && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-800 dark:text-blue-200">
<strong>Instrucciones:</strong> {quizData.instructions}
</p>
</div>
)}
{maxAttempts > 0 && (
<span className="text-[10px] font-bold uppercase tracking-widest px-3 py-1 rounded-full bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/5 text-gray-500 dark:text-gray-400">
Intento {attempts} / {maxAttempts}
@@ -75,9 +329,15 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
</div>
<div className="space-y-8">
{questions.map((q) => (
<fieldset key={q.id} className="space-y-4 p-8 glass border-black/5 dark:border-white/5 rounded-3xl bg-black/[0.02] dark:bg-black/20" aria-labelledby={`q-${q.id}-text`}>
<legend id={`q-${q.id}-text`} className="font-bold text-xl text-gray-900 dark:text-gray-100 leading-tight mb-4">{q.question}</legend>
{questions.map((q, qIdx) => (
<fieldset
key={q.id}
className="space-y-4 p-8 glass border-black/5 dark:border-white/5 rounded-3xl bg-black/[0.02] dark:bg-black/20"
aria-labelledby={`q-${q.id}-text`}
>
<legend id={`q-${q.id}-text`} className="font-bold text-xl text-gray-900 dark:text-gray-100 leading-tight mb-4">
{qIdx + 1}. {q.question}
</legend>
<div
className="grid gap-3"
role={q.type === 'multiple-select' ? 'group' : 'radiogroup'}
@@ -103,11 +363,13 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
return (
<button
key={oIdx}
type="button"
role={q.type === 'multiple-select' ? 'checkbox' : 'radio'}
aria-checked={isSelected}
aria-disabled={submitted}
aria-disabled={submitted || submitting}
onClick={() => handleAnswer(q.id, oIdx, q.type === 'multiple-select')}
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold outline-none focus:ring-2 focus:ring-blue-500/50 ${style}`}
className={`p-5 rounded-xl border transition-all text-left text-sm font-bold outline-none focus:ring-2 focus:ring-blue-500/50 disabled:cursor-not-allowed ${style}`}
disabled={submitted || submitting}
>
<div className="flex items-center justify-between">
<span>{opt}</span>
@@ -123,34 +385,61 @@ export default function QuizPlayer({ id, title, quizData, allowRetry = true, max
);
})}
</div>
{/* Show explanation after submission if enabled */}
{submitted && q.explanation && quizData.show_feedback && (
<div className="mt-4 p-4 bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg">
<p className="text-sm font-medium text-purple-900 dark:text-purple-100 mb-1">
💡 Explicación:
</p>
<p className="text-sm text-purple-700 dark:text-purple-300">
{q.explanation}
</p>
</div>
)}
</fieldset>
))}
{allowRetry && (
<>
{!submitted && questions.length > 0 && (
<button
onClick={handleValidate}
disabled={maxAttempts > 0 && attempts >= maxAttempts}
className={`btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 ${maxAttempts > 0 && attempts >= maxAttempts ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{maxAttempts > 0 && attempts >= maxAttempts ? 'Máximo de Intentos Alcanzado' : 'Validar Respuestas'}
</button>
{!submitted && questions.length > 0 && (
<button
onClick={handleValidate}
disabled={submitting || (maxAttempts > 0 && attempts >= maxAttempts)}
className={`btn-premium w-full py-5 font-black text-xs uppercase tracking-[0.2em] shadow-xl shadow-blue-500/20 disabled:opacity-50 disabled:cursor-not-allowed ${
isSingleAttempt ? 'bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800' : ''
}`}
>
{submitting ? 'Enviando...' : (
isSingleAttempt
? 'Enviar Respuestas (1 Solo Intento)'
: maxAttempts > 0 && attempts >= maxAttempts
? 'Máximo de Intentos Alcanzado'
: 'Validar Respuestas'
)}
{submitted && (
<button
onClick={() => {
if (maxAttempts > 0 && attempts >= maxAttempts) return;
setSubmitted(false);
setUserAnswers({});
}}
disabled={maxAttempts > 0 && attempts >= maxAttempts}
className={`w-full py-5 glass text-blue-600 dark:text-blue-400 font-black text-xs uppercase tracking-[0.2em] hover:bg-black/5 dark:hover:bg-white/5 transition-all rounded-3xl border-black/5 dark:border-white/5 ${maxAttempts > 0 && attempts >= maxAttempts ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{maxAttempts > 0 && attempts >= maxAttempts ? 'Máximo de Intentos Alcanzado' : 'Intentar de Nuevo'}
</button>
</button>
)}
{submitted && score !== null && (
<div className="bg-gradient-to-r from-green-50 to-blue-50 border-2 border-green-300 dark:border-green-700 rounded-2xl p-6 text-center">
<div className="text-sm font-bold text-gray-600 dark:text-gray-300 mb-2">Tu Puntuación</div>
<div className="text-5xl font-black text-green-600 dark:text-green-400 mb-4">
{score}%
</div>
{quizData.passing_score && (
<div className={`text-sm font-bold ${
score >= quizData.passing_score
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}>
{score >= quizData.passing_score ? '✓ Aprobado' : '✗ No Aprobado'}
(Mínimo: {quizData.passing_score}%)
</div>
)}
</>
{quizData.show_feedback && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
📍 Revisa las explicaciones de cada pregunta más arriba
</p>
)}
</div>
)}
</div>
</div>
@@ -0,0 +1,312 @@
'use client';
import React, { useState, useEffect } from 'react';
import { ShieldCheck, TrendingUp, Users, AlertTriangle, DollarSign, Activity } from 'lucide-react';
interface TokenUsage {
user_id: string;
email: string;
full_name: string;
role: string;
total_tokens: number;
input_tokens: number;
output_tokens: number;
ai_requests: number;
last_used: string;
estimated_cost_usd: number;
}
interface TokenStats {
total_tokens: number;
total_input: number;
total_output: number;
total_requests: number;
total_cost_usd: number;
top_user_tokens: number;
avg_tokens_per_user: number;
}
export default function AdminTokenTracking() {
const [usage, setUsage] = useState<TokenUsage[]>([]);
const [stats, setStats] = useState<TokenStats | null>(null);
const [loading, setLoading] = useState(true);
const [filterRole, setFilterRole] = useState<string>('');
const [sortBy, setSortBy] = useState<'total_tokens' | 'ai_requests' | 'estimated_cost_usd'>('total_tokens');
useEffect(() => {
loadTokenUsage();
}, []);
const loadTokenUsage = async () => {
try {
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/token-usage`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
if (response.ok) {
const data = await response.json();
setUsage(data.usage || []);
setStats(data.stats);
}
} catch (error) {
console.error('Failed to load token usage:', error);
} finally {
setLoading(false);
}
};
const filteredUsage = usage
.filter(u => !filterRole || u.role === filterRole)
.sort((a, b) => b[sortBy] - a[sortBy]);
const formatNumber = (num: number) => {
return new Intl.NumberFormat('en-US').format(num);
};
const formatCurrency = (num: number) => {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(num);
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ShieldCheck className="w-8 h-8 text-indigo-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Control Global - Token Usage
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
Monitoreo de tokens de IA y costos del sistema
</p>
</div>
</div>
</div>
</div>
</div>
{/* Stats Cards */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{stats && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Total Tokens</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{formatNumber(stats.total_tokens)}
</p>
</div>
<TrendingUp className="w-8 h-8 text-blue-600" />
</div>
<div className="mt-2 text-xs text-gray-500">
Input: {formatNumber(stats.total_input)} | Output: {formatNumber(stats.total_output)}
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Requests IA</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{formatNumber(stats.total_requests)}
</p>
</div>
<Activity className="w-8 h-8 text-green-600" />
</div>
<div className="mt-2 text-xs text-gray-500">
Avg: {formatNumber(stats.avg_tokens_per_user)} tokens/user
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Costo Estimado</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{formatCurrency(stats.total_cost_usd)}
</p>
</div>
<DollarSign className="w-8 h-8 text-green-600" />
</div>
<div className="mt-2 text-xs text-gray-500">
Top user: {formatNumber(stats.top_user_tokens)} tokens
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Usuarios Activos</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{usage.length}
</p>
</div>
<Users className="w-8 h-8 text-purple-600" />
</div>
<div className="mt-2 text-xs text-gray-500">
Monitoreando uso de IA
</div>
</div>
</div>
{/* Alerts */}
{usage.some(u => u.total_tokens > 1000000) && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-6 flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-yellow-600 shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-yellow-900 dark:text-yellow-100 text-sm">
Usuarios con alto consumo detectado
</h4>
<p className="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
{usage.filter(u => u.total_tokens > 1000000).length} usuario(s) han superado 1M de tokens.
Considere implementar límites de uso.
</p>
</div>
</div>
)}
{/* Filters */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 mb-6">
<div className="flex items-center gap-4">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Filtrar por Rol
</label>
<select
value={filterRole}
onChange={(e) => setFilterRole(e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white"
>
<option value="">Todos</option>
<option value="student">Estudiantes</option>
<option value="instructor">Instructores</option>
<option value="admin">Admins</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Ordenar por
</label>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white"
>
<option value="total_tokens">Total Tokens</option>
<option value="ai_requests">Requests IA</option>
<option value="estimated_cost_usd">Costo USD</option>
</select>
</div>
</div>
</div>
{/* Usage Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-gray-900 dark:text-white">
Uso por Usuario
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Usuario
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Rol
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Input Tokens
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Output Tokens
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Total Tokens
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Requests
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Costo USD
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Última Actividad
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{filteredUsage.map((user) => (
<tr key={user.user_id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{user.full_name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{user.email}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 text-xs font-medium rounded capitalize ${
user.role === 'admin' ? 'bg-purple-100 text-purple-800' :
user.role === 'instructor' ? 'bg-blue-100 text-blue-800' :
'bg-green-100 text-green-800'
}`}>
{user.role}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-500 dark:text-gray-400">
{formatNumber(user.input_tokens)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-500 dark:text-gray-400">
{formatNumber(user.output_tokens)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<span className={`text-sm font-medium ${
user.total_tokens > 1000000 ? 'text-red-600' :
user.total_tokens > 500000 ? 'text-yellow-600' :
'text-gray-900 dark:text-white'
}`}>
{formatNumber(user.total_tokens)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm text-gray-500 dark:text-gray-400">
{formatNumber(user.ai_requests)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-gray-900 dark:text-white">
{formatCurrency(user.estimated_cost_usd)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{new Date(user.last_used).toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
{loading && (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Cargando estadísticas de tokens...</p>
</div>
)}
</div>
</div>
);
}
+354
View File
@@ -0,0 +1,354 @@
'use client';
import React, { useState, useEffect } from 'react';
import { questionBankApi, QuestionBank, QuestionBankFilters, QuestionBankType } from '@/lib/api';
import {
Plus, Search, Filter, Edit2, Trash2, Volume2, VolumeX, Download,
Upload, Sparkles, ChevronDown, ChevronUp, X, Check, AlertCircle,
Headphones, BookOpen, Tag, Hash, Globe
} from 'lucide-react';
import QuestionBankEditor from '@/components/QuestionBank/QuestionBankEditor';
import QuestionBankCard from '@/components/QuestionBank/QuestionBankCard';
import MySQLImportModal from '@/components/QuestionBank/MySQLImportModal';
import AudioGeneratorModal from '@/components/QuestionBank/AudioGeneratorModal';
export default function QuestionBankPage() {
const [questions, setQuestions] = useState<QuestionBank[]>([]);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<QuestionBankFilters>({});
const [searchTerm, setSearchTerm] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [showEditor, setShowEditor] = useState(false);
const [editingQuestion, setEditingQuestion] = useState<QuestionBank | null>(null);
const [showImportModal, setShowImportModal] = useState(false);
const [showAudioModal, setShowAudioModal] = useState(false);
const [selectedForAudio, setSelectedForAudio] = useState<string | null>(null);
const loadQuestions = async () => {
try {
setLoading(true);
const data = await questionBankApi.list(filters);
setQuestions(data);
} catch (error) {
console.error('Failed to load questions:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadQuestions();
}, [filters]);
const handleCreate = () => {
setEditingQuestion(null);
setShowEditor(true);
};
const handleEdit = (question: QuestionBank) => {
setEditingQuestion(question);
setShowEditor(true);
};
const handleDelete = async (id: string) => {
if (!confirm('¿Estás seguro de que deseas eliminar esta pregunta?')) return;
try {
await questionBankApi.delete(id);
await loadQuestions();
} catch (error) {
console.error('Failed to delete question:', error);
alert('Error al eliminar la pregunta');
}
};
const handleImportSuccess = async () => {
setShowImportModal(false);
await loadQuestions();
};
const handleAudioGenerate = (questionId: string) => {
setSelectedForAudio(questionId);
setShowAudioModal(true);
};
const handleAudioSuccess = async () => {
setShowAudioModal(false);
setSelectedForAudio(null);
await loadQuestions();
};
const getQuestionTypeLabel = (type: QuestionBankType) => {
const labels: Record<QuestionBankType, string> = {
'multiple-choice': 'Opción Múltiple',
'true-false': 'Verdadero/Falso',
'short-answer': 'Respuesta Corta',
'essay': 'Ensayo',
'matching': 'Emparejamiento',
'ordering': 'Ordenar',
'fill-in-the-blanks': 'Completar',
'audio-response': 'Respuesta Audio',
'hotspot': 'Hotspot',
'code-lab': 'Código',
};
return labels[type] || type;
};
const getDifficultyColor = (difficulty?: string) => {
switch (difficulty) {
case 'easy': return 'bg-green-100 text-green-800';
case 'medium': return 'bg-yellow-100 text-yellow-800';
case 'hard': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const getSourceBadge = (source?: string) => {
switch (source) {
case 'imported-mysql':
return <span className="flex items-center gap-1 text-xs text-blue-600"><Globe className="w-3 h-3" /> MySQL</span>;
case 'ai-generated':
return <span className="flex items-center gap-1 text-xs text-purple-600"><Sparkles className="w-3 h-3" /> IA</span>;
case 'manual':
return <span className="text-xs text-gray-500">Manual</span>;
default:
return null;
}
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Banco de Preguntas
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Gestiona preguntas reutilizables con audio para tus evaluaciones
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowImportModal(true)}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Upload className="w-4 h-4" />
Importar desde MySQL
</button>
<button
onClick={handleCreate}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
Nueva Pregunta
</button>
</div>
</div>
</div>
</div>
{/* Stats */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="text-2xl font-bold text-gray-900 dark:text-white">{questions.length}</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Total Preguntas</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="text-2xl font-bold text-blue-600">
{questions.filter(q => q.source === 'imported-mysql').length}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Importadas MySQL</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="text-2xl font-bold text-green-600">
{questions.filter(q => q.audio_status === 'ready').length}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Con Audio</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="text-2xl font-bold text-purple-600">
{questions.filter(q => q.source === 'ai-generated').length}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Generadas IA</div>
</div>
</div>
{/* Search and Filters */}
<div className="mb-6 flex gap-3">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Buscar preguntas..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && loadQuestions()}
className="w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-800 dark:text-white"
/>
</div>
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-2 px-4 py-2 border rounded-lg transition-colors ${
showFilters ? 'bg-blue-50 border-blue-500 dark:bg-blue-900/20' : 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Filter className="w-4 h-4" />
Filtros
</button>
</div>
{/* Filter Panel */}
{showFilters && (
<div className="mb-6 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 grid grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tipo de Pregunta
</label>
<select
value={filters.question_type || ''}
onChange={(e) => setFilters({ ...filters, question_type: e.target.value as QuestionBankType || undefined })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="">Todos los tipos</option>
<option value="multiple-choice">Opción Múltiple</option>
<option value="true-false">Verdadero/Falso</option>
<option value="short-answer">Respuesta Corta</option>
<option value="essay">Ensayo</option>
<option value="matching">Emparejamiento</option>
<option value="ordering">Ordenar</option>
<option value="fill-in-the-blanks">Completar</option>
<option value="audio-response">Respuesta Audio</option>
<option value="hotspot">Hotspot</option>
<option value="code-lab">Código</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Dificultad
</label>
<select
value={filters.difficulty || ''}
onChange={(e) => setFilters({ ...filters, difficulty: e.target.value || undefined })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="">Todas</option>
<option value="easy">Fácil</option>
<option value="medium">Media</option>
<option value="hard">Difícil</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Origen
</label>
<select
value={filters.source || ''}
onChange={(e) => setFilters({ ...filters, source: e.target.value || undefined })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="">Todos</option>
<option value="manual">Manual</option>
<option value="imported-mysql">MySQL</option>
<option value="ai-generated">IA</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Audio
</label>
<select
value={filters.has_audio ? 'true' : filters.has_audio === false ? 'false' : ''}
onChange={(e) => setFilters({ ...filters, has_audio: e.target.value === '' ? undefined : e.target.value === 'true' })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="">Todos</option>
<option value="true">Con Audio</option>
<option value="false">Sin Audio</option>
</select>
</div>
</div>
)}
{/* Questions Grid */}
{loading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Cargando preguntas...</p>
</div>
) : questions.length === 0 ? (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-lg border border-dashed border-gray-300 dark:border-gray-700">
<BookOpen className="w-16 h-16 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white">No hay preguntas</h3>
<p className="text-gray-500 dark:text-gray-400 mt-1">
Crea preguntas manualmente o impórtalas desde MySQL
</p>
<div className="mt-4 flex items-center justify-center gap-3">
<button
onClick={handleCreate}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
Nueva Pregunta
</button>
<button
onClick={() => setShowImportModal(true)}
className="flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<Upload className="w-4 h-4" />
Importar desde MySQL
</button>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{questions.map((question) => (
<QuestionBankCard
key={question.id}
question={question}
onEdit={() => handleEdit(question)}
onDelete={() => handleDelete(question.id)}
onGenerateAudio={() => handleAudioGenerate(question.id)}
/>
))}
</div>
)}
</div>
{/* Editor Modal */}
{showEditor && (
<QuestionBankEditor
question={editingQuestion}
onSuccess={() => {
setShowEditor(false);
loadQuestions();
}}
onCancel={() => setShowEditor(false)}
/>
)}
{/* Import Modal */}
{showImportModal && (
<MySQLImportModal
onSuccess={handleImportSuccess}
onCancel={() => setShowImportModal(false)}
/>
)}
{/* Audio Generator Modal */}
{showAudioModal && selectedForAudio && (
<AudioGeneratorModal
questionId={selectedForAudio}
onSuccess={handleAudioSuccess}
onCancel={() => {
setShowAudioModal(false);
setSelectedForAudio(null);
}}
/>
)}
</div>
);
}
+121 -26
View File
@@ -1,25 +1,32 @@
'use client';
import Link from 'next/link';
import { useState } from 'react';
import { useAuth } from '@/context/AuthContext';
import { useTranslation } from '@/context/I18nContext';
import { LayoutDashboard, ShieldCheck, LogOut, Webhook, Settings, Globe, Library, BookOpen, Sun, Moon } from 'lucide-react';
import { LayoutDashboard, ShieldCheck, LogOut, Settings, Globe, Library, BookOpen, Sun, Moon, ChevronDown, FileQuestion, Webhook, User } from 'lucide-react';
import { useBranding } from '@/context/BrandingContext';
import { useTheme } from '@/context/ThemeContext';
import { getImageUrl } from '@/lib/api';
import Image from 'next/image';
// Clase base para TODOS los links de nav — idéntica para todos
// Clase base para TODOS los links de nav
const NAV_LINK = "flex items-center gap-2 text-sm font-bold uppercase tracking-wide transition-colors text-slate-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400";
// Admin variant — mismo tamaño pero con acento añil
// Admin variant
const NAV_LINK_ADMIN = "flex items-center gap-2 text-sm font-bold uppercase tracking-wide transition-colors text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 dark:hover:text-indigo-300";
// Dropdown menu item
const DROPDOWN_ITEM = "flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors";
export function Navbar() {
const { t, language, setLanguage } = useTranslation();
const { user, logout } = useAuth();
const { branding } = useBranding();
const { theme, toggleTheme } = useTheme();
const [coursesOpen, setCoursesOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const platformName = branding?.platform_name || branding?.name || 'Studio';
@@ -52,39 +59,127 @@ export function Navbar() {
<div className="flex items-center gap-6">
<div className="flex items-center gap-5">
<Link href="/" className={NAV_LINK}>
<LayoutDashboard className="w-4 h-4 shrink-0" />
{t('nav.courses')}
</Link>
<Link href="/library/assets" className={NAV_LINK}>
<Library className="w-4 h-4 shrink-0" aria-hidden="true" />
{t('nav.library') || 'Library'}
</Link>
{/* Cursos Dropdown */}
<div className="relative">
<button
onClick={() => setCoursesOpen(!coursesOpen)}
className={`${NAV_LINK} cursor-pointer`}
>
<BookOpen className="w-4 h-4 shrink-0" />
Cursos
<ChevronDown className={`w-3 h-3 transition-transform ${coursesOpen ? 'rotate-180' : ''}`} />
</button>
{coursesOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setCoursesOpen(false)}
/>
<div className="absolute top-full left-0 mt-1 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-20 overflow-hidden">
<Link
href="/"
className={DROPDOWN_ITEM}
onClick={() => setCoursesOpen(false)}
>
<LayoutDashboard className="w-4 h-4" />
Listar Cursos
</Link>
<div className="border-t border-gray-200 dark:border-gray-700 my-1"></div>
<Link
href="/library/assets"
className={DROPDOWN_ITEM}
onClick={() => setCoursesOpen(false)}
>
<Library className="w-4 h-4" />
Librería
</Link>
<Link
href="/question-bank"
className={DROPDOWN_ITEM}
onClick={() => setCoursesOpen(false)}
>
<FileQuestion className="w-4 h-4" />
Banco de Preguntas
</Link>
<div className="border-t border-gray-200 dark:border-gray-700 my-1"></div>
<Link
href="/test-templates"
className={DROPDOWN_ITEM}
onClick={() => setCoursesOpen(false)}
>
<FileQuestion className="w-4 h-4" />
Plantillas de Pruebas
</Link>
</div>
</>
)}
</div>
{user?.role === 'admin' && (
<>
{user.organization_id === '00000000-0000-0000-0000-000000000001' && (
<Link href="/admin" className={NAV_LINK_ADMIN}>
<ShieldCheck className="w-4 h-4 shrink-0" aria-hidden="true" />
{t('nav.globalControl')}
<ShieldCheck className="w-4 h-4 shrink-0" />
Control Global
</Link>
)}
<Link href="/settings/webhooks" className={NAV_LINK}>
<Webhook className="w-4 h-4 shrink-0" />
{t('nav.webhooks')}
</Link>
<Link href="/profile" className={NAV_LINK}>
<Settings className="w-4 h-4 shrink-0" />
{t('nav.profile')}
</Link>
{/* Configuración Dropdown */}
<div className="relative">
<button
onClick={() => setSettingsOpen(!settingsOpen)}
className={`${NAV_LINK} cursor-pointer`}
>
<Settings className="w-4 h-4 shrink-0" />
Configuración
<ChevronDown className={`w-3 h-3 transition-transform ${settingsOpen ? 'rotate-180' : ''}`} />
</button>
{settingsOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setSettingsOpen(false)}
/>
<div className="absolute top-full left-0 mt-1 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-20 overflow-hidden">
<Link
href="/settings/webhooks"
className={DROPDOWN_ITEM}
onClick={() => setSettingsOpen(false)}
>
<Webhook className="w-4 h-4" />
Webhooks
</Link>
<Link
href="/profile"
className={DROPDOWN_ITEM}
onClick={() => setSettingsOpen(false)}
>
<User className="w-4 h-4" />
Perfil
</Link>
<Link
href="/settings"
className={DROPDOWN_ITEM}
onClick={() => setSettingsOpen(false)}
>
<Settings className="w-4 h-4" />
General
</Link>
</div>
</>
)}
</div>
</>
)}
<Link href="/settings" className={NAV_LINK}>
<Settings className="w-4 h-4 shrink-0" />
{t('nav.settings') || 'Settings'}
</Link>
{user?.role !== 'admin' && (
<Link href="/settings" className={NAV_LINK}>
<Settings className="w-4 h-4 shrink-0" />
Configuración
</Link>
)}
</div>
<div className="h-6 w-px bg-black/10 dark:bg-white/10 mx-1" />
@@ -0,0 +1,320 @@
'use client';
import React, { useState, useEffect } from 'react';
import { questionBankApi, QuestionBank } from '@/lib/api';
import { X, Volume2, Check, AlertCircle, Play, Pause } from 'lucide-react';
interface AudioGeneratorModalProps {
questionId: string;
onSuccess?: () => void;
onCancel?: () => void;
}
export default function AudioGeneratorModal({ questionId, onSuccess, onCancel }: AudioGeneratorModalProps) {
const [question, setQuestion] = useState<QuestionBank | null>(null);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [generated, setGenerated] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [audio, setAudio] = useState<HTMLAudioElement | null>(null);
const [voice, setVoice] = useState('v2/en_speaker_1');
const [speed, setSpeed] = useState(1.0);
const [customText, setCustomText] = useState('');
const voices = [
{ id: 'v2/en_speaker_0', name: 'English Speaker 0', language: 'en' },
{ id: 'v2/en_speaker_1', name: 'English Speaker 1', language: 'en' },
{ id: 'v2/en_speaker_6', name: 'English Speaker 6', language: 'en' },
{ id: 'v2/es_speaker_0', name: 'Spanish Speaker 0', language: 'es' },
{ id: 'v2/es_speaker_1', name: 'Spanish Speaker 1', language: 'es' },
{ id: 'v2/es_speaker_3', name: 'Spanish Speaker 3', language: 'es' },
];
useEffect(() => {
loadQuestion();
}, [questionId]);
const loadQuestion = async () => {
try {
const data = await questionBankApi.get(questionId);
setQuestion(data);
setCustomText(data.question_text);
} catch (error) {
console.error('Failed to load question:', error);
setError('No se pudo cargar la pregunta');
} finally {
setLoading(false);
}
};
const handleGenerate = async () => {
try {
setGenerating(true);
setError(null);
await questionBankApi.generateAudio(questionId, customText, voice, speed);
// Poll for completion
let attempts = 0;
const maxAttempts = 30; // 30 seconds max
while (attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 1000));
const updated = await questionBankApi.get(questionId);
if (updated.audio_status === 'ready') {
setGenerated(true);
setQuestion(updated);
break;
} else if (updated.audio_status === 'failed') {
setError('Error al generar el audio');
break;
}
attempts++;
}
if (attempts >= maxAttempts) {
setError('Tiempo de espera agotado. El audio puede estar generándose aún.');
}
} catch (error: any) {
console.error('Audio generation failed:', error);
setError(error.message || 'Error al generar audio');
} finally {
setGenerating(false);
}
};
const handlePlay = () => {
if (!question?.audio_url) return;
if (audio) {
audio.remove();
}
const audioEl = new Audio(question.audio_url);
audioEl.onended = () => setIsPlaying(false);
audioEl.onerror = () => {
setIsPlaying(false);
alert('Error al reproducir el audio');
};
setAudio(audioEl);
setIsPlaying(true);
audioEl.play();
};
const handleStop = () => {
if (audio) {
audio.pause();
audio.currentTime = 0;
}
setIsPlaying(false);
};
if (loading) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-lg w-full p-8 text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600 dark:text-gray-400">Cargando pregunta...</p>
</div>
</div>
);
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<Volume2 className="w-6 h-6 text-blue-600" />
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Generar Audio con Bark
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Convierte el texto de la pregunta a audio
</p>
</div>
</div>
<button
onClick={onCancel}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Question Preview */}
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Texto de la pregunta
</label>
<p className="text-gray-900 dark:text-white text-sm">
{question?.question_text}
</p>
</div>
{/* Custom Text */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Texto para audio (opcional)
</label>
<textarea
value={customText}
onChange={(e) => setCustomText(e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Deja en blanco para usar el texto de la pregunta"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Puedes personalizar el texto si quieres una pronunciación diferente
</p>
</div>
{/* Voice Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Voz
</label>
<select
value={voice}
onChange={(e) => setVoice(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
{voices.map((v) => (
<option key={v.id} value={v.id}>
{v.name} ({v.language === 'en' ? 'Inglés' : 'Español'})
</option>
))}
</select>
</div>
{/* Speed */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Velocidad
</label>
<div className="flex items-center gap-3">
<input
type="range"
min="0.5"
max="2.0"
step="0.1"
value={speed}
onChange={(e) => setSpeed(parseFloat(e.target.value))}
className="flex-1"
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-300 w-12 text-right">
{speed.toFixed(1)}x
</span>
</div>
</div>
{/* Audio Preview */}
{question?.audio_status === 'ready' && question.audio_url && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 dark:bg-green-900/40 rounded-full flex items-center justify-center">
<Volume2 className="w-5 h-5 text-green-600" />
</div>
<div>
<p className="text-sm font-medium text-green-900 dark:text-green-100">
Audio generado
</p>
<p className="text-xs text-green-700 dark:text-green-300">
Haz click para escuchar
</p>
</div>
</div>
<button
onClick={isPlaying ? handleStop : handlePlay}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
{isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
{isPlaying ? 'Detener' : 'Reproducir'}
</button>
</div>
</div>
)}
{/* Generating Status */}
{generating && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<div>
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
Generando audio...
</p>
<p className="text-xs text-blue-700 dark:text-blue-300">
Esto puede tomar unos segundos
</p>
</div>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-600" />
<div>
<p className="text-sm font-medium text-red-900 dark:text-red-100">
Error
</p>
<p className="text-xs text-red-700 dark:text-red-300">
{error}
</p>
</div>
</div>
)}
{/* Success Message */}
{generated && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 flex items-center gap-3">
<Check className="w-5 h-5 text-green-600" />
<div>
<p className="text-sm font-medium text-green-900 dark:text-green-100">
¡Audio generado exitosamente!
</p>
<p className="text-xs text-green-700 dark:text-green-300">
El audio está disponible para los estudiantes
</p>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="p-6 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
<button
type="button"
onClick={onCancel}
disabled={generating}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{generated ? 'Cerrar' : 'Cancelar'}
</button>
{!question?.audio_url && (
<button
onClick={handleGenerate}
disabled={generating || generated}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<Volume2 className="w-4 h-4" />
{generating ? 'Generando...' : 'Generar Audio'}
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,241 @@
'use client';
import React, { useState } from 'react';
import { questionBankApi } from '@/lib/api';
import { X, Upload, FileSpreadsheet, Check, AlertCircle, Download } from 'lucide-react';
interface ExcelImportModalProps {
onSuccess?: () => void;
onCancel?: () => void;
}
export default function ExcelImportModal({ onSuccess, onCancel }: ExcelImportModalProps) {
const [excelFile, setExcelFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) {
alert('Por favor sube un archivo Excel (.xlsx o .xls)');
return;
}
setExcelFile(file);
setError(null);
}
};
const handleUpload = async () => {
if (!excelFile) {
alert('Selecciona un archivo primero');
return;
}
try {
setUploading(true);
setError(null);
const formData = new FormData();
formData.append('file', excelFile);
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/question-bank/import-excel`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: formData,
});
if (!response.ok) {
throw new Error('Error al importar');
}
const data = await response.json();
setResult(data);
setTimeout(() => {
onSuccess?.();
}, 2000);
} catch (err: any) {
console.error('Excel import failed:', err);
setError(err.message || 'Error al importar');
} finally {
setUploading(false);
}
};
const downloadTemplate = () => {
// Create a simple template explanation
alert('Descargando plantilla...\n\nColumnas requeridas:\n1. question_text - Texto de la pregunta\n2. question_type - multiple-choice, true-false, etc.\n3. options - ["A","B","C","D"]\n4. correct_answer - 0, 1, 2, o 3\n5. explanation - Explicación (opcional)\n6. difficulty - easy, medium, hard\n7. tags - tag1,tag2,tag3');
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<FileSpreadsheet className="w-6 h-6 text-green-600" />
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Importar desde Excel
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Sube preguntas desde un archivo Excel (.xlsx)
</p>
</div>
</div>
<button
onClick={onCancel}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* Info Box */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2 text-sm">
¿Cómo funciona?
</h4>
<ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
<li> Crea un Excel con columnas: question_text, question_type, options, correct_answer, explanation, difficulty, tags</li>
<li> question_type: multiple-choice, true-false, short-answer, etc.</li>
<li> options: Formato JSON ["A","B","C","D"] o separado por comas</li>
<li> correct_answer: Índice de la opción correcta (0, 1, 2, 3)</li>
<li> Todas las preguntas se importarán al banco</li>
</ul>
</div>
{/* Download Template Button */}
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div className="flex items-center gap-3">
<Download className="w-5 h-5 text-gray-500" />
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">Plantilla de ejemplo</p>
<p className="text-xs text-gray-500 dark:text-gray-400">Descarga una plantilla con el formato correcto</p>
</div>
</div>
<button
onClick={downloadTemplate}
className="px-4 py-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors text-sm font-medium"
>
Descargar
</button>
</div>
{/* File Upload */}
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center">
<input
type="file"
accept=".xlsx,.xls"
onChange={handleFileChange}
className="hidden"
id="excel-upload"
/>
<label
htmlFor="excel-upload"
className="cursor-pointer flex flex-col items-center gap-3"
>
<FileSpreadsheet className="w-12 h-12 text-green-500" />
<div>
<span className="text-sm font-medium text-blue-600 hover:text-blue-700">
Subir archivo Excel
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{' '}o arrastrar aquí
</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500">
.xlsx, .xls - Máx 10MB
</p>
</label>
{excelFile && (
<div className="mt-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div className="flex items-center gap-2 text-sm text-green-800 dark:text-green-200">
<Check className="w-4 h-4" />
Archivo seleccionado: {excelFile.name}
<span className="text-xs">({(excelFile.size / 1024 / 1024).toFixed(2)} MB)</span>
</div>
</div>
)}
</div>
{/* Result Messages */}
{result && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center gap-3 mb-2">
<Check className="w-5 h-5 text-green-600" />
<p className="font-semibold text-green-900 dark:text-green-100">
¡Importación completada!
</p>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-green-700 dark:text-green-300">Importadas:</span>
<span className="ml-2 font-bold text-green-900 dark:text-green-100">
{result.imported}
</span>
</div>
<div>
<span className="text-green-700 dark:text-green-300">Saltadas:</span>
<span className="ml-2 font-bold text-green-900 dark:text-green-100">
{result.skipped}
</span>
</div>
{result.error && (
<div>
<span className="text-green-700 dark:text-green-300">Errores:</span>
<span className="ml-2 font-bold text-green-900 dark:text-green-100">
{result.error}
</span>
</div>
)}
</div>
</div>
)}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-600" />
<div>
<p className="text-sm font-medium text-red-900 dark:text-red-100">
Error
</p>
<p className="text-xs text-red-700 dark:text-red-300">
{error}
</p>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="p-6 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
<button
type="button"
onClick={onCancel}
disabled={uploading}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{result ? 'Cerrar' : 'Cancelar'}
</button>
<button
onClick={handleUpload}
disabled={uploading || !excelFile}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<Upload className="w-4 h-4" />
{uploading ? 'Importando...' : 'Importar Excel'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,193 @@
'use client';
import React, { useState, useEffect } from 'react';
import { X, Download, Database, Check, AlertCircle, Upload, FileSpreadsheet } from 'lucide-react';
import ExcelImportModal from './ExcelImportModal';
interface MySQLImportModalProps {
onSuccess?: () => void;
onCancel?: () => void;
}
export default function MySQLImportModal({ onSuccess, onCancel }: MySQLImportModalProps) {
const [showExcelModal, setShowExcelModal] = useState(false);
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const handleImportAll = async () => {
if (!confirm('¿Estás seguro de importar TODAS las preguntas de MySQL? Esto puede tomar varios minutos.')) {
return;
}
try {
setImporting(true);
setError(null);
const result = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/question-bank/import-mysql-all`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
}).then(r => r.json());
setImportResult(result);
setTimeout(() => {
onSuccess?.();
}, 1500);
} catch (error: any) {
console.error('Import all failed:', error);
setError(error.message || 'Error al importar todas las preguntas');
} finally {
setImporting(false);
}
};
if (showExcelModal) {
return (
<ExcelImportModal
onSuccess={onSuccess}
onCancel={() => setShowExcelModal(false)}
/>
);
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<Database className="w-6 h-6 text-blue-600" />
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Importar Preguntas
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
Desde MySQL o Excel
</p>
</div>
</div>
<button
onClick={onCancel}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-4">
{/* MySQL Import */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3 mb-3">
<Database className="w-5 h-5 text-blue-600 shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-blue-900 dark:text-blue-100 text-sm mb-2">
Importar desde MySQL
</h4>
<ul className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
<li> Todas las preguntas del banco de diagnóstico</li>
<li> Sin duplicados automáticos</li>
<li> Mapeo automático de tipos</li>
</ul>
</div>
</div>
<button
onClick={handleImportAll}
disabled={importing}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 text-sm font-medium"
>
{importing ? 'Importando...' : 'Importar Todo desde MySQL'}
</button>
</div>
{/* Excel Import */}
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-start gap-3 mb-3">
<FileSpreadsheet className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
<div>
<h4 className="font-semibold text-green-900 dark:text-green-100 text-sm mb-2">
Importar desde Excel
</h4>
<ul className="text-xs text-green-800 dark:text-green-200 space-y-1">
<li> Archivo .xlsx con tus preguntas</li>
<li> Plantilla personalizada</li>
<li> Múltiples tipos de preguntas</li>
</ul>
</div>
</div>
<button
onClick={() => setShowExcelModal(true)}
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm font-medium"
>
Importar desde Excel
</button>
</div>
{/* Result Messages */}
{importResult && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center gap-3 mb-2">
<Check className="w-5 h-5 text-green-600" />
<p className="font-semibold text-green-900 dark:text-green-100 text-sm">
¡Importación completada!
</p>
</div>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<span className="text-green-700 dark:text-green-300">Importadas:</span>
<span className="ml-2 font-bold text-green-900 dark:text-green-100">
{importResult.imported}
</span>
</div>
<div>
<span className="text-green-700 dark:text-green-300">Saltadas:</span>
<span className="ml-2 font-bold text-green-900 dark:text-green-100">
{importResult.skipped}
</span>
</div>
{importResult.updated !== undefined && (
<div>
<span className="text-green-700 dark:text-green-300">Actualizadas:</span>
<span className="ml-2 font-bold text-green-900 dark:text-green-100">
{importResult.updated}
</span>
</div>
)}
</div>
</div>
)}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-600" />
<div>
<p className="text-sm font-medium text-red-900 dark:text-red-100">
Error
</p>
<p className="text-xs text-red-700 dark:text-red-300">
{error}
</p>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="p-6 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end gap-3">
<button
type="button"
onClick={onCancel}
disabled={importing}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors disabled:opacity-50"
>
{importResult ? 'Cerrar' : 'Cancelar'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,198 @@
'use client';
import React, { useState } from 'react';
import { QuestionBank } from '@/lib/api';
import { Edit2, Trash2, Volume2, VolumeX, Sparkles, Globe, MoreVertical, Play, Pause } from 'lucide-react';
interface QuestionBankCardProps {
question: QuestionBank;
onEdit: () => void;
onDelete: () => void;
onGenerateAudio: () => void;
}
export default function QuestionBankCard({ question, onEdit, onDelete, onGenerateAudio }: QuestionBankCardProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [audio, setAudio] = useState<HTMLAudioElement | null>(null);
const getQuestionTypeLabel = (type: string) => {
const labels: Record<string, string> = {
'multiple-choice': 'Opción Múltiple',
'true-false': 'Verdadero/Falso',
'short-answer': 'Respuesta Corta',
'essay': 'Ensayo',
'matching': 'Emparejamiento',
'ordering': 'Ordenar',
'fill-in-the-blanks': 'Completar',
'audio-response': 'Respuesta Audio',
'hotspot': 'Hotspot',
'code-lab': 'Código',
};
return labels[type] || type;
};
const getDifficultyColor = (difficulty?: string) => {
switch (difficulty) {
case 'easy': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
case 'medium': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
case 'hard': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
default: return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400';
}
};
const handlePlayAudio = () => {
if (!question.audio_url) return;
if (audio) {
audio.remove();
}
const audioEl = new Audio(question.audio_url);
audioEl.onended = () => setIsPlaying(false);
audioEl.onerror = () => {
setIsPlaying(false);
alert('Error al reproducir el audio');
};
setAudio(audioEl);
setIsPlaying(true);
audioEl.play();
};
const handleStopAudio = () => {
if (audio) {
audio.pause();
audio.currentTime = 0;
}
setIsPlaying(false);
};
return (
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-lg transition-shadow">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 text-xs font-medium rounded">
{getQuestionTypeLabel(question.question_type)}
</span>
<span className={`px-2 py-1 text-xs font-medium rounded ${getDifficultyColor(question.difficulty)}`}>
{question.difficulty || 'Medium'}
</span>
{question.skill_assessed && (
<span className="px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 text-xs font-medium rounded capitalize">
📊 {question.skill_assessed}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1">
{question.audio_status === 'ready' ? (
<button
onClick={isPlaying ? handleStopAudio : handlePlayAudio}
className="p-1.5 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors"
title={isPlaying ? 'Detener audio' : 'Reproducir audio'}
>
{isPlaying ? <Pause className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
</button>
) : (
<button
onClick={onGenerateAudio}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
title="Generar audio"
>
<VolumeX className="w-4 h-4" />
</button>
)}
<button
onClick={onEdit}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded transition-colors"
title="Editar"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={onDelete}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{/* Question Text */}
<p className="text-gray-900 dark:text-white font-medium mb-3 line-clamp-3">
{question.question_text}
</p>
{/* Audio Player */}
{question.audio_url && (
<div className="mb-3">
<div className="flex items-center gap-2 mb-1">
<Volume2 className="w-3 h-3 text-green-600" />
<span className="text-xs text-gray-500 dark:text-gray-400">Audio disponible</span>
</div>
<audio controls src={question.audio_url} className="w-full h-8" />
</div>
)}
{/* Options Preview */}
{question.options && (
<div className="mb-3 space-y-1">
{Array.isArray(question.options) && question.options.slice(0, 3).map((opt: string, idx: number) => (
<div key={idx} className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-2">
<span className="w-4 h-4 rounded border border-gray-300 dark:border-gray-600 flex items-center justify-center text-[10px]">
{String.fromCharCode(65 + idx)}
</span>
<span className="line-clamp-1">{opt}</span>
</div>
))}
{Array.isArray(question.options) && question.options.length > 3 && (
<div className="text-xs text-gray-400">+{question.options.length - 3} opciones más</div>
)}
</div>
)}
{/* Footer */}
<div className="pt-3 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
{question.points} pts
</span>
{question.source && (
<div className="flex items-center gap-1">
{question.source === 'imported-mysql' && (
<span className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
<Globe className="w-3 h-3" /> MySQL
</span>
)}
{question.source === 'ai-generated' && (
<span className="flex items-center gap-1 text-xs text-purple-600 dark:text-purple-400">
<Sparkles className="w-3 h-3" /> IA
</span>
)}
</div>
)}
</div>
{question.audio_status === 'ready' && (
<span className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
<Volume2 className="w-3 h-3" /> Audio listo
</span>
)}
{question.audio_status === 'generating' && (
<span className="text-xs text-yellow-600 dark:text-yellow-400">Generando audio...</span>
)}
</div>
{/* Usage Stats */}
{question.usage_count && question.usage_count > 0 && (
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<span className="text-xs text-gray-500 dark:text-gray-400">
Usada {question.usage_count} veces
</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,539 @@
'use client';
import React, { useState } from 'react';
import { questionBankApi, QuestionBank, CreateQuestionBankPayload, QuestionBankType } from '@/lib/api';
import { X, Save, Sparkles, Volume2, Trash2, Upload } from 'lucide-react';
interface QuestionBankEditorProps {
question?: QuestionBank | null;
onSuccess?: () => void;
onCancel?: () => void;
}
export default function QuestionBankEditor({ question, onSuccess, onCancel }: QuestionBankEditorProps) {
const [formData, setFormData] = useState<CreateQuestionBankPayload>({
question_text: question?.question_text || '',
question_type: question?.question_type || 'multiple-choice',
options: question?.options || (question?.question_type === 'true-false' ? ['Verdadero', 'Falso'] : undefined),
correct_answer: question?.correct_answer,
explanation: question?.explanation || '',
points: question?.points || 1,
difficulty: question?.difficulty || 'medium',
tags: question?.tags || [],
media_url: question?.media_url,
media_type: question?.media_type,
generate_audio: false,
skill_assessed: question?.skill_assessed,
});
const [audioFile, setAudioFile] = useState<File | null>(null);
const [audioPreview, setAudioPreview] = useState<string | null>(question?.audio_url || null);
const [uploadingAudio, setUploadingAudio] = useState(false);
const [newTag, setNewTag] = useState('');
const [saving, setSaving] = useState(false);
const [generatingAI, setGeneratingAI] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.question_text.trim()) {
alert('El texto de la pregunta es obligatorio');
return;
}
try {
setSaving(true);
let audioUrl = audioPreview;
// Upload audio file if provided
if (audioFile) {
setUploadingAudio(true);
const audioFormData = new FormData();
audioFormData.append('file', audioFile);
audioFormData.append('type', 'question_audio');
const uploadResponse = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/assets/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: audioFormData,
});
if (uploadResponse.ok) {
const uploadResult = await uploadResponse.json();
audioUrl = uploadResult.url || uploadResult.file_url;
} else {
console.warn('Audio upload failed, continuing without audio');
}
setUploadingAudio(false);
}
const payload = {
...formData,
audio_url: audioUrl || undefined,
audio_text: formData.question_text, // Suggestion for what the audio should say
};
if (question) {
await questionBankApi.update(question.id, payload);
} else {
await questionBankApi.create(payload);
}
onSuccess?.();
} catch (error) {
console.error('Failed to save question:', error);
alert('Error al guardar la pregunta');
} finally {
setSaving(false);
}
};
const handleAddTag = () => {
if (newTag.trim() && !formData.tags?.includes(newTag.trim())) {
setFormData({
...formData,
tags: [...(formData.tags || []), newTag.trim()],
});
setNewTag('');
}
};
const handleRemoveTag = (tagToRemove: string) => {
setFormData({
...formData,
tags: formData.tags?.filter(tag => tag !== tagToRemove) || [],
});
};
const handleAddOption = () => {
const currentOptions = formData.options || [];
setFormData({
...formData,
options: [...currentOptions, `Opción ${currentOptions.length + 1}`],
});
};
const handleRemoveOption = (index: number) => {
const newOptions = (formData.options || []).filter((_: any, i: number) => i !== index);
setFormData({ ...formData, options: newOptions });
// Adjust correct answer if needed
if (typeof formData.correct_answer === 'number' && formData.correct_answer === index) {
setFormData({ ...formData, correct_answer: undefined });
} else if (typeof formData.correct_answer === 'number' && formData.correct_answer > index) {
setFormData({ ...formData, correct_answer: formData.correct_answer - 1 });
}
};
const handleGenerateWithAI = async () => {
if (!formData.question_text.trim()) {
alert('Ingresa una pregunta base o contexto para que la IA genere las opciones y explicación');
return;
}
// Define las 4 habilidades del inglés que deben cubrirse
const skills = ['reading', 'listening', 'speaking', 'writing'];
const randomSkill = skills[Math.floor(Math.random() * skills.length)];
const skillPrompts = {
reading: 'Focus on reading comprehension, vocabulary in context, or text analysis.',
listening: 'Focus on listening comprehension, audio-based understanding, or spoken dialogue interpretation.',
speaking: 'Focus on oral production, pronunciation, or conversational response.',
writing: 'Focus on written production, grammar in writing, or composition skills.'
};
try {
setGeneratingAI(true);
// AI generation with skill verification
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/lessons/dummy/generate-quiz`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({
context: `Generate a question that assesses ${randomSkill.toUpperCase()} skill. ${skillPrompts[randomSkill as keyof typeof skillPrompts]}
Base question context: ${formData.question_text}
IMPORTANT: The question must:
1. Test the ${randomSkill} skill specifically
2. Be pedagogically sound for English language learning
3. Include clear options and a thorough explanation
4. Be appropriate for the difficulty level: ${formData.difficulty}
Return the question with options and explanation.`,
quiz_type: formData.question_type,
}),
});
if (!response.ok) {
throw new Error('Error al generar con IA');
}
const data = await response.json();
if (data.blocks && data.blocks.length > 0) {
const block = data.blocks[0];
if (block.quiz_data && block.quiz_data.questions && block.quiz_data.questions.length > 0) {
const aiQuestion = block.quiz_data.questions[0];
setFormData({
...formData,
options: aiQuestion.options || formData.options,
correct_answer: aiQuestion.correct,
explanation: aiQuestion.explanation
? `${aiQuestion.explanation}\n\n📊 Skill assessed: ${randomSkill.toUpperCase()}`
: `This question assesses ${randomSkill.toUpperCase()} skills.`,
tags: [...(formData.tags || []), randomSkill, 'ai-generated'],
});
alert(`IA generó las opciones y explicación enfocadas en la habilidad: ${randomSkill.toUpperCase()}`);
}
}
} catch (error) {
console.error('AI generation error:', error);
alert('Error al generar con IA. Asegúrate de tener Ollama configurado.');
} finally {
setGeneratingAI(false);
}
};
const isMultipleChoice = formData.question_type === 'multiple-choice' || formData.question_type === 'true-false';
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-3xl w-full my-8 max-h-[calc(100vh-4rem)] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800 z-10">
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{question ? 'Editar Pregunta' : 'Nueva Pregunta'}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{question ? 'Modifica los detalles de la pregunta' : 'Crea una nueva pregunta para el banco'}
</p>
</div>
<button
onClick={onCancel}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{/* Question Type & Difficulty */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tipo de Pregunta *
</label>
<select
value={formData.question_type}
onChange={(e) => setFormData({
...formData,
question_type: e.target.value as QuestionBankType,
options: e.target.value === 'true-false' ? ['Verdadero', 'Falso'] : formData.options,
})}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="multiple-choice">Opción Múltiple</option>
<option value="true-false">Verdadero/Falso</option>
<option value="short-answer">Respuesta Corta</option>
<option value="essay">Ensayo</option>
<option value="matching">Emparejamiento</option>
<option value="ordering">Ordenar</option>
<option value="fill-in-the-blanks">Completar Espacios</option>
<option value="audio-response">Respuesta de Audio</option>
<option value="hotspot">Hotspot (Imagen)</option>
<option value="code-lab">Ejercicio de Código</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Dificultad
</label>
<select
value={formData.difficulty}
onChange={(e) => setFormData({ ...formData, difficulty: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
>
<option value="easy">Fácil</option>
<option value="medium">Media</option>
<option value="hard">Difícil</option>
</select>
</div>
</div>
{/* Question Text */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Pregunta *
</label>
<textarea
value={formData.question_text}
onChange={(e) => setFormData({ ...formData, question_text: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Escribe el enunciado de la pregunta..."
required
/>
</div>
{/* Options for Multiple Choice */}
{isMultipleChoice && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Opciones (marca la correcta)
</label>
<button
type="button"
onClick={handleAddOption}
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<Upload className="w-3 h-3" />
Agregar opción
</button>
</div>
{formData.options?.map((option: any, idx: number) => (
<div key={idx} className="flex items-center gap-2">
<input
type="radio"
name="correct_answer"
checked={formData.correct_answer === idx}
onChange={() => setFormData({ ...formData, correct_answer: idx })}
className="w-4 h-4 text-blue-600"
/>
<input
type="text"
value={option}
onChange={(e) => {
const newOptions = [...(formData.options || [])];
newOptions[idx] = e.target.value;
setFormData({ ...formData, options: newOptions });
}}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm dark:bg-gray-700 dark:text-white"
placeholder={`Opción ${idx + 1}`}
/>
<button
type="button"
onClick={() => handleRemoveOption(idx)}
className="p-2 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{/* AI Generation Button */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleGenerateWithAI}
disabled={generatingAI || !formData.question_text.trim()}
className="flex items-center gap-2 px-3 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
<Sparkles className="w-4 h-4" />
{generatingAI ? 'Generando...' : 'Generar con IA'}
</button>
<span className="text-xs text-gray-500 dark:text-gray-400">
Genera opciones y explicación automáticamente
</span>
</div>
{/* Explanation */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1 flex items-center gap-2">
Explicación / Feedback
<Sparkles className="w-3 h-3 text-purple-600" />
</label>
<textarea
value={formData.explanation}
onChange={(e) => setFormData({ ...formData, explanation: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 dark:bg-gray-700 dark:text-white"
placeholder="Explicación que se mostrará al estudiante después de responder..."
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Esta explicación ayuda al estudiante a entender por qué su respuesta fue correcta o incorrecta.
</p>
</div>
{/* Audio Upload Section */}
<div className="space-y-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center gap-2">
<Volume2 className="w-4 h-4" />
Audio de la Pregunta (Opcional)
</label>
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-4 text-center">
{audioPreview ? (
<div className="space-y-3">
<audio controls src={audioPreview} className="w-full" />
<button
type="button"
onClick={() => {
setAudioPreview(null);
setAudioFile(null);
}}
className="text-sm text-red-600 hover:text-red-700 flex items-center gap-1 mx-auto"
>
<Trash2 className="w-3 h-3" />
Eliminar audio
</button>
</div>
) : (
<div>
<input
type="file"
accept="audio/*"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setAudioFile(file);
setAudioPreview(URL.createObjectURL(file));
}
}}
className="hidden"
id="audio-upload"
/>
<label
htmlFor="audio-upload"
className="cursor-pointer flex flex-col items-center gap-2"
>
<Volume2 className="w-8 h-8 text-gray-400" />
<div>
<span className="text-sm font-medium text-blue-600 hover:text-blue-700">
Subir archivo de audio
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
{' '}o arrastrar aquí
</span>
</div>
<p className="text-xs text-gray-400 dark:text-gray-500">
MP3, WAV, OGG - Máx 10MB
</p>
<p className="text-xs text-blue-600 dark:text-blue-400 mt-2">
💡 Sugerencia: Graba la pregunta con tu celular y súbelala aquí
</p>
</label>
</div>
)}
</div>
{audioFile && !audioPreview && (
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600"></div>
Subiendo: {audioFile.name} ({(audioFile.size / 1024 / 1024).toFixed(2)} MB)
</div>
)}
</div>
{/* Points & Tags */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Puntos
</label>
<input
type="number"
value={formData.points}
onChange={(e) => setFormData({ ...formData, points: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
min="1"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Etiquetas
</label>
<div className="flex gap-2">
<input
type="text"
value={newTag}
onChange={(e) => setNewTag(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddTag())}
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
placeholder="Agregar etiqueta..."
/>
<button
type="button"
onClick={handleAddTag}
className="px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
<Upload className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Tags Display */}
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{formData.tags.map((tag, idx) => (
<span
key={idx}
className="px-3 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded-full text-sm flex items-center gap-1"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:text-blue-600"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Audio Generation Option */}
<div className="flex items-center gap-3 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<input
type="checkbox"
id="generate_audio"
checked={formData.generate_audio}
onChange={(e) => setFormData({ ...formData, generate_audio: e.target.checked })}
className="w-4 h-4 text-green-600 rounded"
/>
<label htmlFor="generate_audio" className="flex items-center gap-2 text-sm text-green-800 dark:text-green-200">
<Volume2 className="w-4 h-4" />
Generar audio automáticamente con Bark después de guardar
</label>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-6 border-t border-gray-200 dark:border-gray-700 sticky bottom-0 bg-white dark:bg-gray-800 -mx-6 px-6 py-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? 'Guardando...' : (question ? 'Actualizar' : 'Guardar Pregunta')}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -1,8 +1,30 @@
'use client';
import React, { useState } from 'react';
import { cmsApi, CreateTestTemplatePayload, CourseLevel, CourseType, TestType } from '@/lib/api';
import { X, Save, Plus, Trash2 } from 'lucide-react';
import { cmsApi, CreateTestTemplatePayload, CourseLevel, CourseType, TestType, QuestionType } from '@/lib/api';
import { X, Save, Plus, Trash2, Sparkles, ChevronDown, ChevronUp, Copy, GripVertical, Edit2 } from 'lucide-react';
interface Section {
id: string;
title: string;
description?: string;
section_order: number;
points: number;
instructions?: string;
}
interface Question {
id: string;
section_id?: string;
question_order: number;
question_type: QuestionType;
question_text: string;
options?: string[];
correct_answer?: number | number[] | string;
explanation?: string;
points: number;
metadata?: any;
}
interface TestTemplateFormProps {
onSuccess?: () => void;
@@ -24,20 +46,59 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
tags: [],
});
const [sections, setSections] = useState<Section[]>([]);
const [questions, setQuestions] = useState<Question[]>([]);
const [newTag, setNewTag] = useState('');
const [saving, setSaving] = useState(false);
const [generatingAI, setGeneratingAI] = useState(false);
const [expandedQuestion, setExpandedQuestion] = useState<string | null>(null);
const [aiContext, setAiContext] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
alert('El nombre es obligatorio');
return;
}
if (questions.length === 0) {
alert('Debes agregar al menos una pregunta');
return;
}
try {
setSaving(true);
await cmsApi.createTestTemplate(formData);
// Primero crear la plantilla
const template = await cmsApi.createTestTemplate(formData);
// Luego agregar secciones
for (const section of sections) {
await cmsApi.createTemplateSection(template.id, {
title: section.title,
description: section.description,
section_order: section.section_order,
points: section.points,
instructions: section.instructions,
});
}
// Finalmente agregar preguntas
for (const question of questions) {
await cmsApi.createTemplateQuestion(template.id, {
section_id: question.section_id,
question_order: question.question_order,
question_type: question.question_type,
question_text: question.question_text,
options: question.options,
correct_answer: question.correct_answer,
explanation: question.explanation,
points: question.points,
metadata: question.metadata,
});
}
alert('Plantilla creada exitosamente');
onSuccess?.();
} catch (error) {
@@ -65,12 +126,138 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
});
};
const handleAddSection = () => {
const newSection: Section = {
id: `section-${Date.now()}`,
title: `Sección ${sections.length + 1}`,
description: '',
section_order: sections.length,
points: 0,
instructions: '',
};
setSections([...sections, newSection]);
};
const handleRemoveSection = (sectionId: string) => {
setSections(sections.filter(s => s.id !== sectionId));
setQuestions(questions.filter(q => q.section_id !== sectionId));
};
const handleUpdateSection = (sectionId: string, updates: Partial<Section>) => {
setSections(sections.map(s => s.id === sectionId ? { ...s, ...updates } : s));
};
const handleAddQuestion = (sectionId?: string) => {
const newQuestion: Question = {
id: `question-${Date.now()}`,
section_id: sectionId,
question_order: questions.filter(q => q.section_id === sectionId).length,
question_type: 'multiple-choice',
question_text: '',
options: ['Opción 1', 'Opción 2', 'Opción 3', 'Opción 4'],
correct_answer: 0,
explanation: '',
points: 1,
};
setQuestions([...questions, newQuestion]);
setExpandedQuestion(newQuestion.id);
};
const handleUpdateQuestion = (questionId: string, updates: Partial<Question>) => {
setQuestions(questions.map(q => q.id === questionId ? { ...q, ...updates } : q));
};
const handleRemoveQuestion = (questionId: string) => {
setQuestions(questions.filter(q => q.id !== questionId));
};
const handleGenerateWithAI = async () => {
if (!aiContext.trim()) {
alert('Ingresa el contexto para generar las preguntas (ej: tema de la lección, contenido, etc.)');
return;
}
try {
setGeneratingAI(true);
// Usar el endpoint de generación de quiz existente
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/lessons/dummy/generate-quiz`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
body: JSON.stringify({
context: aiContext,
quiz_type: 'multiple-choice',
}),
});
if (!response.ok) {
throw new Error('Error al generar con IA');
}
const data = await response.json();
// Parsear las preguntas generadas
if (data.blocks && data.blocks.length > 0) {
const block = data.blocks[0];
if (block.quiz_data && block.quiz_data.questions) {
const generatedQuestions: Question[] = block.quiz_data.questions.map((q: any, idx: number) => ({
id: `q-${Date.now()}-${idx}`,
section_id: undefined,
question_order: idx,
question_type: q.type || 'multiple-choice',
question_text: q.question,
options: q.options,
correct_answer: q.correct,
explanation: q.explanation || '',
points: 1,
}));
setQuestions([...questions, ...generatedQuestions]);
alert(`Se generaron ${generatedQuestions.length} preguntas con IA`);
}
}
} catch (error) {
console.error('AI generation error:', error);
alert('Error al generar preguntas con IA. Asegúrate de tener Ollama configurado.');
} finally {
setGeneratingAI(false);
}
};
const handleDuplicateQuestion = (question: Question) => {
const duplicate: Question = {
...question,
id: `question-${Date.now()}`,
question_order: questions.length,
question_text: `${question.question_text} (copia)`,
};
setQuestions([...questions, duplicate]);
};
const getQuestionTypeLabel = (type: QuestionType) => {
const labels: Record<QuestionType, string> = {
'multiple-choice': 'Opción Múltiple',
'true-false': 'Verdadero/Falso',
'short-answer': 'Respuesta Corta',
'essay': 'Ensayo',
'matching': 'Emparejamiento',
'ordering': 'Ordenar',
};
return labels[type] || type;
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
<div className="bg-white rounded-lg max-w-5xl w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-900">Nueva Plantilla de Prueba</h2>
<div className="flex items-center justify-between p-6 border-b border-gray-200 sticky top-0 bg-white z-10">
<div>
<h2 className="text-xl font-bold text-gray-900">Nueva Plantilla de Prueba</h2>
<p className="text-sm text-gray-500">Crea preguntas y secciones para tu evaluación</p>
</div>
<button
onClick={onCancel}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
@@ -80,23 +267,45 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-6 space-y-6">
<form onSubmit={handleSubmit} className="p-6 space-y-8">
{/* Basic Info */}
<div className="space-y-4">
<h3 className="font-semibold text-gray-900">Información Básica</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Ej: Final Exam - Beginner 1"
required
/>
<h3 className="font-semibold text-gray-900 flex items-center gap-2">
<Edit2 className="w-4 h-4" />
Información Básica
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Nombre *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Ej: Final Exam - Beginner 1"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo de Prueba *
</label>
<select
value={formData.test_type}
onChange={(e) => setFormData({ ...formData, test_type: e.target.value as TestType })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="CA">Continuous Assessment (CA)</option>
<option value="MWT">Midterm Written Test (MWT)</option>
<option value="MOT">Midterm Oral Test (MOT)</option>
<option value="FOT">Final Oral Test (FOT)</option>
<option value="FWT">Final Written Test (FWT)</option>
</select>
</div>
</div>
<div>
@@ -106,17 +315,12 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Descripción de la plantilla..."
/>
</div>
</div>
{/* Classification */}
<div className="space-y-4">
<h3 className="font-semibold text-gray-900">Clasificación</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
@@ -155,31 +359,7 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo de Prueba *
</label>
<select
value={formData.test_type}
onChange={(e) => setFormData({ ...formData, test_type: e.target.value as TestType })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="CA">Continuous Assessment (CA)</option>
<option value="MWT">Midterm Written Test (MWT)</option>
<option value="MOT">Midterm Oral Test (MOT)</option>
<option value="FOT">Final Oral Test (FOT)</option>
<option value="FWT">Final Written Test (FWT)</option>
</select>
</div>
</div>
</div>
{/* Configuration */}
<div className="space-y-4">
<h3 className="font-semibold text-gray-900">Configuración</h3>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Duración (minutos) *
Duración (min) *
</label>
<input
type="number"
@@ -189,7 +369,9 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
min="1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Puntuación Mínima (%) *
@@ -217,25 +399,314 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Instrucciones
</label>
<textarea
value={formData.instructions}
onChange={(e) => setFormData({ ...formData, instructions: e.target.value })}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Instrucciones generales para la prueba..."
{/* AI Generation */}
<div className="space-y-4 p-4 bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl border border-purple-100">
<h3 className="font-semibold text-gray-900 flex items-center gap-2">
<Sparkles className="w-4 h-4 text-purple-600" />
Generar Preguntas con IA
</h3>
<div className="flex gap-2">
<input
type="text"
value={aiContext}
onChange={(e) => setAiContext(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Describe el tema o contenido (ej: 'Past Simple tense, vocabulary about travel, 5 questions')"
disabled={generatingAI}
/>
<button
type="button"
onClick={handleGenerateWithAI}
disabled={generatingAI}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<Sparkles className="w-4 h-4" />
{generatingAI ? 'Generando...' : 'Generar'}
</button>
</div>
<p className="text-xs text-gray-500">
La IA generará preguntas de opción múltiple con explicaciones automáticas.
</p>
</div>
{/* Sections */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-gray-900 flex items-center gap-2">
<Copy className="w-4 h-4" />
Secciones y Preguntas
</h3>
<div className="flex gap-2">
<button
type="button"
onClick={handleAddSection}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Agregar Sección
</button>
<button
type="button"
onClick={() => handleAddQuestion(undefined)}
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Agregar Pregunta
</button>
</div>
</div>
{/* Sections List */}
{sections.map((section, sIdx) => (
<div key={section.id} className="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 grid grid-cols-3 gap-3">
<input
type="text"
value={section.title}
onChange={(e) => handleUpdateSection(section.id, { title: e.target.value })}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm font-medium"
placeholder="Título de sección"
/>
<input
type="number"
value={section.points}
onChange={(e) => handleUpdateSection(section.id, { points: parseInt(e.target.value) || 0 })}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="Puntos"
/>
<input
type="text"
value={section.instructions || ''}
onChange={(e) => handleUpdateSection(section.id, { instructions: e.target.value })}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="Instrucciones (opcional)"
/>
</div>
<button
type="button"
onClick={() => handleRemoveSection(section.id)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Questions for this section */}
<div className="ml-4 space-y-2">
{questions.filter(q => q.section_id === section.id).map((q) => (
<div key={q.id} className="bg-white border border-gray-200 rounded p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">{q.question_text || 'Sin título'}</span>
<div className="flex items-center gap-1">
<span className="text-xs text-gray-500">{q.points} pts</span>
<button
type="button"
onClick={() => handleRemoveQuestion(q.id)}
className="p-1 text-red-600 hover:bg-red-50 rounded"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
</div>
))}
<button
type="button"
onClick={() => handleAddQuestion(section.id)}
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Agregar pregunta a esta sección
</button>
</div>
</div>
))}
{/* Questions without section */}
<div className="space-y-3">
{questions.filter(q => !q.section_id).map((question, qIdx) => (
<div key={question.id} className="border border-gray-200 rounded-lg overflow-hidden">
{/* Question Header */}
<div
className="bg-gray-50 p-4 flex items-center justify-between cursor-pointer hover:bg-gray-100"
onClick={() => setExpandedQuestion(expandedQuestion === question.id ? null : question.id)}
>
<div className="flex items-center gap-3 flex-1">
<GripVertical className="w-4 h-4 text-gray-400" />
<span className="text-sm font-medium text-gray-500">Pregunta {qIdx + 1}</span>
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs rounded-full">
{getQuestionTypeLabel(question.question_type)}
</span>
<span className="text-xs text-gray-500">{question.points} puntos</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleDuplicateQuestion(question)}
className="p-1.5 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="Duplicar"
>
<Copy className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleRemoveQuestion(question.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="Eliminar"
>
<Trash2 className="w-4 h-4" />
</button>
{expandedQuestion === question.id ? (
<ChevronUp className="w-4 h-4 text-gray-400" />
) : (
<ChevronDown className="w-4 h-4 text-gray-400" />
)}
</div>
</div>
{/* Question Editor */}
{expandedQuestion === question.id && (
<div className="p-4 space-y-4 bg-white">
{/* Question Type */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo de Pregunta
</label>
<select
value={question.question_type}
onChange={(e) => handleUpdateQuestion(question.id, { question_type: e.target.value as QuestionType })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="multiple-choice">Opción Múltiple</option>
<option value="true-false">Verdadero/Falso</option>
<option value="short-answer">Respuesta Corta</option>
<option value="essay">Ensayo</option>
<option value="matching">Emparejamiento</option>
<option value="ordering">Ordenar</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Puntos
</label>
<input
type="number"
value={question.points}
onChange={(e) => handleUpdateQuestion(question.id, { points: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
min="1"
/>
</div>
</div>
{/* Question Text */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Pregunta *
</label>
<textarea
value={question.question_text}
onChange={(e) => handleUpdateQuestion(question.id, { question_text: e.target.value })}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
placeholder="Escribe el enunciado de la pregunta..."
required
/>
</div>
{/* Options for multiple choice */}
{(question.question_type === 'multiple-choice' || question.question_type === 'true-false') && (
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Opciones (marca la correcta)
</label>
{question.options?.map((option, oIdx) => (
<div key={oIdx} className="flex items-center gap-2">
<input
type="radio"
name={`correct-${question.id}`}
checked={question.correct_answer === oIdx}
onChange={() => handleUpdateQuestion(question.id, { correct_answer: oIdx })}
className="w-4 h-4 text-blue-600"
/>
<input
type="text"
value={option}
onChange={(e) => {
const newOptions = [...(question.options || [])];
newOptions[oIdx] = e.target.value;
handleUpdateQuestion(question.id, { options: newOptions });
}}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder={`Opción ${oIdx + 1}`}
/>
<button
type="button"
onClick={() => {
const newOptions = question.options?.filter((_, idx) => idx !== oIdx);
handleUpdateQuestion(question.id, { options: newOptions });
}}
className="p-2 text-red-600 hover:bg-red-50 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
<button
type="button"
onClick={() => handleUpdateQuestion(question.id, {
options: [...(question.options || []), `Opción ${(question.options?.length || 0) + 1}`]
})}
className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Agregar opción
</button>
</div>
)}
{/* Explanation (AI generated field) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-2">
Explicación / Feedback
<Sparkles className="w-3 h-3 text-purple-600" />
<span className="text-xs text-gray-500 font-normal">(Generado por IA, editable)</span>
</label>
<textarea
value={question.explanation || ''}
onChange={(e) => handleUpdateQuestion(question.id, { explanation: e.target.value })}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
placeholder="Explicación que se mostrará al estudiante después de responder..."
/>
<p className="text-xs text-gray-500 mt-1">
Esta explicación se mostrará al alumno después de que responda la pregunta.
</p>
</div>
</div>
)}
</div>
))}
</div>
{questions.length === 0 && (
<div className="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-300">
<Copy className="w-12 h-12 text-gray-300 mx-auto mb-2" />
<p className="text-gray-500 text-sm">No hay preguntas agregadas</p>
<p className="text-gray-400 text-xs">Usa la IA o agrega preguntas manualmente</p>
</div>
)}
</div>
{/* Tags */}
<div className="space-y-4">
<h3 className="font-semibold text-gray-900">Etiquetas</h3>
<div className="flex gap-2">
<input
type="text"
@@ -277,7 +748,7 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-3 pt-6 border-t border-gray-200">
<div className="flex items-center justify-end gap-3 pt-6 border-t border-gray-200 sticky bottom-0 bg-white">
<button
type="button"
onClick={onCancel}
@@ -287,11 +758,11 @@ export default function TestTemplateForm({ onSuccess, onCancel }: TestTemplateFo
</button>
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50"
disabled={saving || questions.length === 0}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
{saving ? 'Guardando...' : 'Guardar Plantilla'}
{saving ? 'Guardando...' : `Guardar Plantilla (${questions.length} preguntas)`}
</button>
</div>
</form>
@@ -1,8 +1,8 @@
'use client';
import React, { useState, useEffect } from 'react';
import { cmsApi, TestTemplate, TestTemplateFilters, CourseLevel, CourseType, TestType } from '@/lib/api';
import { Plus, Search, Filter, Edit2, Trash2, Eye, Copy, BookOpen, Clock, Target, Tag } from 'lucide-react';
import { cmsApi, TestTemplate, TestTemplateFilters, CourseLevel, CourseType, TestType, Course } from '@/lib/api';
import { Plus, Search, Filter, Edit2, Trash2, Eye, Copy, BookOpen, Clock, Target, Tag, ExternalLink, X } from 'lucide-react';
interface TestTemplateManagerProps {
onSelectTemplate?: (template: TestTemplate) => void;
@@ -15,6 +15,12 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
const [filters, setFilters] = useState<TestTemplateFilters>({});
const [showFilters, setShowFilters] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [showApplyModal, setShowApplyModal] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<TestTemplate | null>(null);
const [courses, setCourses] = useState<Course[]>([]);
const [selectedCourse, setSelectedCourse] = useState<string>('');
const [selectedLesson, setSelectedLesson] = useState<string>('');
const [applying, setApplying] = useState(false);
const loadTemplates = async () => {
try {
@@ -45,10 +51,39 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
};
const handleApplyTemplate = async (template: TestTemplate) => {
if (onSelectTemplate) {
onSelectTemplate(template);
} else {
alert(`Plantilla "${template.name}" seleccionada. (Implementar lógica de aplicación)`);
setSelectedTemplate(template);
setShowApplyModal(true);
loadCourses();
};
const loadCourses = async () => {
try {
const data = await cmsApi.getCourses();
setCourses(data);
} catch (error) {
console.error('Failed to load courses:', error);
}
};
const handleApplyToLesson = async () => {
if (!selectedTemplate || !selectedLesson) {
alert('Selecciona una lección');
return;
}
try {
setApplying(true);
await cmsApi.applyTemplateToLesson(selectedTemplate.id, selectedLesson);
alert('Plantilla aplicada exitosamente a la lección');
setShowApplyModal(false);
setSelectedTemplate(null);
setSelectedCourse('');
setSelectedLesson('');
} catch (error) {
console.error('Failed to apply template:', error);
alert('Error al aplicar la plantilla');
} finally {
setApplying(false);
}
};
@@ -299,6 +334,178 @@ export default function TestTemplateManager({ onSelectTemplate, onCreateTemplate
))}
</div>
)}
{/* Apply Template Modal */}
{showApplyModal && selectedTemplate && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
<div>
<h3 className="text-xl font-bold text-gray-900">Aplicar Plantilla a Lección</h3>
<p className="text-sm text-gray-500">{selectedTemplate.name}</p>
</div>
<button
onClick={() => {
setShowApplyModal(false);
setSelectedTemplate(null);
}}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-4">
{/* Template Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="font-semibold text-blue-900 mb-2">Información de la Plantilla</h4>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-blue-700">Tipo:</span>
<span className="ml-2 font-medium">{getTestTypeLabel(selectedTemplate.test_type)}</span>
</div>
<div>
<span className="text-blue-700">Duración:</span>
<span className="ml-2 font-medium">{selectedTemplate.duration_minutes} min</span>
</div>
<div>
<span className="text-blue-700">Puntos:</span>
<span className="ml-2 font-medium">{selectedTemplate.total_points}</span>
</div>
<div>
<span className="text-blue-700">Aprobación:</span>
<span className="ml-2 font-medium">{selectedTemplate.passing_score}%</span>
</div>
</div>
<div className="mt-3 text-xs text-blue-700">
<p> Esta plantilla tiene <strong>1 solo intento</strong> por alumno.</p>
<p>📝 Los alumnos podrán ver sus respuestas permanentemente.</p>
</div>
</div>
{/* Select Course */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Curso
</label>
<select
value={selectedCourse}
onChange={(e) => {
setSelectedCourse(e.target.value);
setSelectedLesson('');
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">Selecciona un curso...</option>
{courses.map((course) => (
<option key={course.id} value={course.id}>
{course.title}
</option>
))}
</select>
</div>
{/* Select Lesson */}
{selectedCourse && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Lección (debe ser de tipo "Quiz" o estar vacía)
</label>
<LessonSelector
courseId={selectedCourse}
selectedLesson={selectedLesson}
onSelect={setSelectedLesson}
/>
</div>
)}
{/* Info Box */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<h4 className="font-semibold text-yellow-900 mb-2 text-sm">¿Qué sucederá?</h4>
<ul className="text-xs text-yellow-800 space-y-1">
<li> La lección se convertirá en un quiz con las {selectedTemplate.name} preguntas de la plantilla</li>
<li> Se configurará con <strong>1 solo intento</strong> por alumno</li>
<li> Las calificaciones se sincronizarán con la base de datos MySQL</li>
<li> Los alumnos podrán revisar sus respuestas después de completar</li>
</ul>
</div>
</div>
<div className="p-6 border-t border-gray-200 flex items-center justify-end gap-3">
<button
type="button"
onClick={() => {
setShowApplyModal(false);
setSelectedTemplate(null);
}}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancelar
</button>
<button
type="button"
onClick={handleApplyToLesson}
disabled={applying || !selectedLesson}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-2 disabled:opacity-50"
>
<Copy className="w-4 h-4" />
{applying ? 'Aplicando...' : 'Aplicar Plantilla'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
// Lesson Selector Component
function LessonSelector({ courseId, selectedLesson, onSelect }: {
courseId: string;
selectedLesson: string;
onSelect: (lessonId: string) => void;
}) {
const [lessons, setLessons] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadLessons = async () => {
try {
setLoading(true);
const course = await cmsApi.getCourse(courseId);
if (course.modules) {
const allLessons = course.modules.flatMap((m: any) => m.lessons || []);
setLessons(allLessons);
}
} catch (error) {
console.error('Failed to load lessons:', error);
} finally {
setLoading(false);
}
};
loadLessons();
}, [courseId]);
if (loading) {
return <div className="text-sm text-gray-500">Cargando lecciones...</div>;
}
if (lessons.length === 0) {
return <div className="text-sm text-red-600">Este curso no tiene lecciones</div>;
}
return (
<select
value={selectedLesson}
onChange={(e) => onSelect(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">Selecciona una lección...</option>
{lessons.map((lesson) => (
<option key={lesson.id} value={lesson.id}>
{lesson.title} ({lesson.content_type || 'Sin contenido'})
</option>
))}
</select>
);
}
+94
View File
@@ -923,6 +923,100 @@ export const cmsApi = {
apiFetch(`/test-templates/${templateId}/sections/${sectionId}`, { method: 'DELETE' }, false),
applyTemplateToLesson: (templateId: string, lessonId: string, gradingCategoryId?: string): Promise<void> =>
apiFetch(`/test-templates/${templateId}/apply`, { method: 'POST', body: JSON.stringify({ lesson_id: lessonId, grading_category_id: gradingCategoryId }) }, false),
generateQuestionsWithRAG: (courseId?: number, topic?: string, numQuestions?: number): Promise<TestTemplateQuestion[]> =>
apiFetch('/test-templates/generate-with-rag', { method: 'POST', body: JSON.stringify({ course_id: courseId, topic, num_questions: numQuestions }) }, false),
};
// ==================== Question Bank ====================
export type QuestionBankType = 'multiple-choice' | 'true-false' | 'short-answer' | 'essay' | 'matching' | 'ordering' | 'fill-in-the-blanks' | 'audio-response' | 'hotspot' | 'code-lab';
export interface QuestionBank {
id: string;
organization_id: string;
question_text: string;
question_type: QuestionBankType;
options?: any;
correct_answer?: any;
explanation?: string;
audio_url?: string;
audio_text?: string;
audio_status?: 'pending' | 'generating' | 'ready' | 'failed';
audio_metadata?: any;
media_url?: string;
media_type?: string;
points: number;
difficulty?: 'easy' | 'medium' | 'hard';
tags?: string[];
skill_assessed?: 'reading' | 'listening' | 'speaking' | 'writing';
source?: 'manual' | 'ai-generated' | 'imported-mysql' | 'imported-csv';
source_metadata?: any;
usage_count?: number;
last_used_at?: string;
is_active: boolean;
is_archived: boolean;
created_by?: string;
created_at: string;
updated_at: string;
}
export interface QuestionBankFilters {
question_type?: QuestionBankType;
difficulty?: string;
tags?: string;
source?: string;
search?: string;
has_audio?: boolean;
}
export interface CreateQuestionBankPayload {
question_text: string;
question_type: QuestionBankType;
options?: any;
correct_answer?: any;
explanation?: string;
points?: number;
difficulty?: string;
tags?: string[];
media_url?: string;
media_type?: string;
generate_audio?: boolean;
skill_assessed?: string;
audio_url?: string;
audio_text?: string;
}
export interface UpdateQuestionBankPayload {
question_text?: string;
question_type?: QuestionBankType;
options?: any;
correct_answer?: any;
explanation?: string;
points?: number;
difficulty?: string;
tags?: string[];
is_active?: boolean;
is_archived?: boolean;
skill_assessed?: string;
audio_url?: string;
audio_text?: string;
}
export const questionBankApi = {
list: (filters?: QuestionBankFilters): Promise<QuestionBank[]> =>
apiFetch('/question-bank', { method: 'GET', query: filters as any }, false),
get: (id: string): Promise<QuestionBank> =>
apiFetch(`/question-bank/${id}`, {}, false),
create: (payload: CreateQuestionBankPayload): Promise<QuestionBank> =>
apiFetch('/question-bank', { method: 'POST', body: JSON.stringify(payload) }, false),
update: (id: string, payload: UpdateQuestionBankPayload): Promise<QuestionBank> =>
apiFetch(`/question-bank/${id}`, { method: 'PUT', body: JSON.stringify(payload) }, false),
delete: (id: string): Promise<void> =>
apiFetch(`/question-bank/${id}`, { method: 'DELETE' }, false),
importFromMySQL: (courseId?: number, questionIds?: number[], importAll?: boolean): Promise<QuestionBank[]> =>
apiFetch('/question-bank/import-mysql', { method: 'POST', body: JSON.stringify({ mysql_course_id: courseId, question_ids: questionIds, import_all: importAll }) }, false),
generateAudio: (id: string, text?: string, voice?: string, speed?: number): Promise<void> =>
apiFetch(`/question-bank/${id}/generate-audio`, { method: 'POST', body: JSON.stringify({ text, voice, speed }) }, false),
};
export const lmsApi = {
File diff suppressed because one or more lines are too long