feat: token count implement
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user