elmer-20 2 months ago
parent 0925fa4575
commit b62acb8d62

@ -0,0 +1,192 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\Area;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class AreaController extends Controller
{
/**
* Listar áreas (con búsqueda, filtro y paginación)
*/
public function index(Request $request)
{
$query = Area::query();
// 🔍 Buscar por nombre o código
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('nombre', 'like', "%{$search}%")
->where('codigo', 'like', "%{$search}%");
});
}
// 🔄 Filtrar por estado
if ($request->filled('activo')) {
$query->where('activo', $request->activo);
}
$areas = $query
->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 10));
return response()->json([
'success' => true,
'data' => $areas
]);
}
/**
* Crear área
*/
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'nombre' => 'required|string|min:3|max:100',
'codigo' => 'required|string|min:2|max:20|regex:/^[A-Z0-9]+$/|unique:areas,codigo',
'descripcion' => 'nullable|string|max:500',
'activo' => 'boolean',
], [
'codigo.regex' => 'El código solo puede contener letras mayúsculas y números'
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors()
], 422);
}
$area = Area::create([
'nombre' => $request->nombre,
'codigo' => strtoupper($request->codigo),
'descripcion' => $request->descripcion,
'activo' => $request->activo ?? true,
]);
return response()->json([
'success' => true,
'message' => 'Área creada correctamente',
'data' => $area
], 201);
}
/**
* Mostrar área
*/
public function show($id)
{
$area = Area::with(['cursos', 'examenes'])->find($id);
if (!$area) {
return response()->json([
'success' => false,
'message' => 'Área no encontrada'
], 404);
}
return response()->json([
'success' => true,
'data' => $area
]);
}
/**
* Actualizar área
*/
public function update(Request $request, $id)
{
$area = Area::find($id);
if (!$area) {
return response()->json([
'success' => false,
'message' => 'Área no encontrada'
], 404);
}
$validator = Validator::make($request->all(), [
'nombre' => 'required|string|min:3|max:100',
'codigo' => 'required|string|min:2|max:20|regex:/^[A-Z0-9]+$/|unique:areas,codigo,' . $id,
'descripcion' => 'nullable|string|max:500',
'activo' => 'boolean',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors()
], 422);
}
$area->update([
'nombre' => $request->nombre,
'codigo' => strtoupper($request->codigo),
'descripcion' => $request->descripcion,
'activo' => $request->activo ?? $area->activo, // mantener el valor actual si no viene
]);
return response()->json([
'success' => true,
'message' => 'Área actualizada correctamente',
'data' => $area
]);
}
/**
* Activar / Desactivar área (NO elimina)
*/
public function toggleEstado($id)
{
$area = Area::find($id);
if (!$area) {
return response()->json([
'success' => false,
'message' => 'Área no encontrada'
], 404);
}
$area->activo = !$area->activo;
$area->save();
return response()->json([
'success' => true,
'message' => $area->activo ? 'Área activada' : 'Área desactivada',
'data' => $area
]);
}
/**
* Eliminar área (solo si no tiene cursos ni exámenes)
*/
public function destroy($id)
{
$area = Area::with(['cursos', 'examenes'])->find($id);
if (!$area) {
return response()->json([
'success' => false,
'message' => 'Área no encontrada'
], 404);
}
if ($area->cursos()->count() > 0 || $area->examenes()->count() > 0) {
return response()->json([
'success' => false,
'message' => 'No se puede eliminar un área con cursos o exámenes asociados'
], 409);
}
$area->delete();
return response()->json([
'success' => true,
'message' => 'Área eliminada correctamente'
]);
}
}

@ -0,0 +1,188 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\Curso;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class CursoController extends Controller
{
/**
* Listar cursos (con búsqueda, filtro y paginación)
*/
public function index(Request $request)
{
$query = Curso::query();
// 🔍 Buscar por nombre o código
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('nombre', 'like', "%{$search}%")
->orWhere('codigo', 'like', "%{$search}%");
});
}
// 🔄 Filtrar por estado
if ($request->filled('activo')) {
$query->where('activo', $request->activo);
}
$cursos = $query
->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 10));
return response()->json([
'success' => true,
'data' => $cursos
]);
}
/**
* Crear curso
*/
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'nombre' => 'required|string|min:3|max:100',
'codigo' => 'required|string|min:2|max:20|regex:/^[A-Z0-9]+$/|unique:cursos,codigo',
'activo' => 'boolean',
], [
'codigo.regex' => 'El código solo puede contener letras mayúsculas y números'
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors()
], 422);
}
$curso = Curso::create([
'nombre' => $request->nombre,
'codigo' => strtoupper($request->codigo),
'activo' => $request->activo ?? true,
]);
return response()->json([
'success' => true,
'message' => 'Curso creado correctamente',
'data' => $curso
], 201);
}
/**
* Mostrar curso
*/
public function show($id)
{
$curso = Curso::with('areas')->find($id);
if (!$curso) {
return response()->json([
'success' => false,
'message' => 'Curso no encontrado'
], 404);
}
return response()->json([
'success' => true,
'data' => $curso
]);
}
/**
* Actualizar curso
*/
public function update(Request $request, $id)
{
$curso = Curso::find($id);
if (!$curso) {
return response()->json([
'success' => false,
'message' => 'Curso no encontrado'
], 404);
}
$validator = Validator::make($request->all(), [
'nombre' => 'required|string|min:3|max:100',
'codigo' => 'required|string|min:2|max:20|regex:/^[A-Z0-9]+$/|unique:cursos,codigo,' . $id,
'activo' => 'boolean',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors()
], 422);
}
$curso->update([
'nombre' => $request->nombre,
'codigo' => strtoupper($request->codigo),
'activo' => $request->activo ?? $curso->activo,
]);
return response()->json([
'success' => true,
'message' => 'Curso actualizado correctamente',
'data' => $curso
]);
}
/**
* Activar / Desactivar curso
*/
public function toggleEstado($id)
{
$curso = Curso::find($id);
if (!$curso) {
return response()->json([
'success' => false,
'message' => 'Curso no encontrado'
], 404);
}
$curso->activo = !$curso->activo;
$curso->save();
return response()->json([
'success' => true,
'message' => $curso->activo ? 'Curso activado' : 'Curso desactivado',
'data' => $curso
]);
}
/**
* Eliminar curso (solo si no tiene áreas ni preguntas asociadas)
*/
public function destroy($id)
{
$curso = Curso::with(['areas', 'preguntas'])->find($id);
if (!$curso) {
return response()->json([
'success' => false,
'message' => 'Curso no encontrado'
], 404);
}
if ($curso->areas()->count() > 0 || $curso->preguntas()->count() > 0) {
return response()->json([
'success' => false,
'message' => 'No se puede eliminar un curso con áreas o preguntas asociadas'
], 409);
}
$curso->delete();
return response()->json([
'success' => true,
'message' => 'Curso eliminado correctamente'
]);
}
}

@ -0,0 +1,488 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\Academia;
use App\Models\User;
use App\Models\Examen;
use App\Models\Pregunta;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Carbon\Carbon;
class ExamenesController extends Controller
{
/**
* Obtener exámenes de la academia
*/
public function getExamenes(Request $request)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$academia = Academia::where('admin_academia_id', $user->id)->first();
if (!$academia) {
return response()->json([
'success' => false,
'message' => 'Academia no encontrada'
], 404);
}
$query = $academia->examenes()
->withCount(['preguntas', 'intentos' => function($query) {
$query->where('estado', 'finalizado');
}])
->latest();
// Filtros
if ($request->has('search')) {
$search = $request->search;
$query->where('titulo', 'like', "%{$search}%");
}
if ($request->has('publicado')) {
$query->where('publicado', $request->publicado);
}
if ($request->has('tipo')) {
$query->where('tipo', $request->tipo);
}
$examenes = $query->paginate(15);
return response()->json([
'success' => true,
'data' => $examenes
]);
} catch (\Exception $e) {
Log::error('Error obteniendo exámenes', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al cargar los exámenes'
], 500);
}
}
/**
* Crear nuevo examen
*/
public function crearExamen(Request $request)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$academia = Academia::where('admin_academia_id', $user->id)->first();
if (!$academia) {
return response()->json([
'success' => false,
'message' => 'Academia no encontrada'
], 404);
}
$validator = Validator::make($request->all(), [
'titulo' => 'required|string|max:255',
'descripcion' => 'nullable|string',
'tipo' => 'required|in:practica,simulacro,evaluacion',
'dificultad' => 'required|in:facil,medio,dificil,avanzado',
'duracion_minutos' => 'required|integer|min:1|max:480',
'intentos_permitidos' => 'required|integer|min:1|max:10',
'puntaje_minimo' => 'required|numeric|min:0|max:100',
'preguntas_aleatorias' => 'boolean',
'mostrar_resultados' => 'boolean',
'mostrar_respuestas' => 'boolean',
'publicado' => 'boolean',
'fecha_inicio' => 'nullable|date',
'fecha_fin' => 'nullable|date|after_or_equal:fecha_inicio',
'configuracion' => 'nullable|array'
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors()
], 422);
}
DB::beginTransaction();
$examen = Examen::create([
'academia_id' => $academia->id,
'titulo' => $request->titulo,
'descripcion' => $request->descripcion,
'tipo' => $request->tipo,
'dificultad' => $request->dificultad,
'duracion_minutos' => $request->duracion_minutos,
'intentos_permitidos' => $request->intentos_permitidos,
'puntaje_minimo' => $request->puntaje_minimo,
// 👇 OJO AQUÍ
'preguntas_aleatorias' => $request->preguntas_aleatorias ?? 0,
'mezclar_opciones' => $request->mezclar_opciones ?? true,
'mostrar_resultados' => $request->mostrar_resultados ?? true,
'mostrar_respuestas' => $request->mostrar_respuestas ?? false,
'mostrar_explicaciones' => $request->mostrar_explicaciones ?? false,
'activar_timer' => $request->activar_timer ?? true,
'permitir_navegacion' => $request->permitir_navegacion ?? true,
'permitir_revisar' => $request->permitir_revisar ?? true,
'publicado' => $request->publicado ?? false,
'fecha_inicio' => $request->fecha_inicio,
'fecha_fin' => $request->fecha_fin,
'orden' => $request->orden ?? 1,
'configuracion' => $request->configuracion ?? []
]);
DB::commit();
Log::info('Examen creado por admin', [
'academia_id' => $academia->id,
'examen_id' => $examen->id,
'admin_id' => $user->id
]);
return response()->json([
'success' => true,
'message' => 'Examen creado exitosamente',
'data' => $examen
], 201);
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error creando examen', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al crear el examen'
], 500);
}
}
/**
* Obtener detalles de un examen
*/
public function getExamen($examenId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$academia = Academia::where('admin_academia_id', $user->id)->first();
if (!$academia) {
return response()->json([
'success' => false,
'message' => 'Academia no encontrada'
], 404);
}
$examen = Examen::where('academia_id', $academia->id)
->with(['preguntas'])
->find($examenId);
if (!$examen) {
return response()->json([
'success' => false,
'message' => 'Examen no encontrado'
], 404);
}
// Estadísticas del examen
$estadisticas = DB::table('intentos_examen')
->where('examen_id', $examenId)
->where('estado', 'finalizado')
->selectRaw('COUNT(*) as total_intentos')
->selectRaw('AVG(porcentaje) as promedio')
->selectRaw('SUM(CASE WHEN aprobado = 1 THEN 1 ELSE 0 END) as aprobados')
->first();
return response()->json([
'success' => true,
'data' => [
'examen' => $examen,
'estadisticas' => $estadisticas
]
]);
} catch (\Exception $e) {
Log::error('Error obteniendo examen', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al cargar el examen'
], 500);
}
}
/**
* Actualizar examen
*/
public function actualizarExamen(Request $request, $examenId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$academia = Academia::where('admin_academia_id', $user->id)->first();
if (!$academia) {
return response()->json([
'success' => false,
'message' => 'Academia no encontrada'
], 404);
}
$examen = Examen::where('academia_id', $academia->id)->find($examenId);
if (!$examen) {
return response()->json([
'success' => false,
'message' => 'Examen no encontrado'
], 404);
}
$validator = Validator::make($request->all(), [
'titulo' => 'sometimes|string|max:255',
'descripcion' => 'nullable|string',
'tipo' => 'sometimes|in:practica,simulacro,evaluacion',
'duracion_minutos' => 'sometimes|integer|min:1|max:480',
'intentos_permitidos' => 'sometimes|integer|min:1|max:10',
'puntaje_minimo' => 'sometimes|numeric|min:0|max:100',
'preguntas_aleatorias' => 'boolean',
'mostrar_resultados' => 'boolean',
'mostrar_respuestas' => 'boolean',
'publicado' => 'boolean',
'fecha_inicio' => 'nullable|date',
'fecha_fin' => 'nullable|date|after_or_equal:fecha_inicio',
'configuracion' => 'nullable|array'
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors()
], 422);
}
$examen->update($request->only([
'titulo', 'descripcion', 'tipo', 'duracion_minutos', 'intentos_permitidos',
'puntaje_minimo', 'preguntas_aleatorias', 'mostrar_resultados',
'mostrar_respuestas', 'publicado', 'fecha_inicio', 'fecha_fin', 'configuracion'
]));
Log::info('Examen actualizado por admin', [
'academia_id' => $academia->id,
'examen_id' => $examen->id,
'admin_id' => $user->id
]);
return response()->json([
'success' => true,
'message' => 'Examen actualizado exitosamente',
'data' => $examen
]);
} catch (\Exception $e) {
Log::error('Error actualizando examen', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al actualizar el examen'
], 500);
}
}
/**
* Eliminar examen
*/
public function eliminarExamen($examenId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$academia = Academia::where('admin_academia_id', $user->id)->first();
if (!$academia) {
return response()->json([
'success' => false,
'message' => 'Academia no encontrada'
], 404);
}
$examen = Examen::where('academia_id', $academia->id)->find($examenId);
if (!$examen) {
return response()->json([
'success' => false,
'message' => 'Examen no encontrado'
], 404);
}
// Verificar si hay intentos realizados
$tieneIntentos = $examen->intentos()->exists();
if ($tieneIntentos) {
return response()->json([
'success' => false,
'message' => 'No se puede eliminar un examen con intentos realizados'
], 400);
}
$examen->delete();
Log::info('Examen eliminado por admin', [
'academia_id' => $academia->id,
'examen_id' => $examenId,
'admin_id' => $user->id
]);
return response()->json([
'success' => true,
'message' => 'Examen eliminado exitosamente'
]);
} catch (\Exception $e) {
Log::error('Error eliminando examen', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al eliminar el examen'
], 500);
}
}
/**
* Obtener resultados de un examen
*/
public function getResultadosExamen($examenId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$academia = Academia::where('admin_academia_id', $user->id)->first();
if (!$academia) {
return response()->json([
'success' => false,
'message' => 'Academia no encontrada'
], 404);
}
$examen = Examen::where('academia_id', $academia->id)->find($examenId);
if (!$examen) {
return response()->json([
'success' => false,
'message' => 'Examen no encontrado'
], 404);
}
$resultados = DB::table('intentos_examen')
->join('users', 'intentos_examen.user_id', '=', 'users.id')
->where('intentos_examen.examen_id', $examenId)
->where('intentos_examen.estado', 'finalizado')
->select(
'users.id as estudiante_id',
'users.name as estudiante_nombre',
'users.email as estudiante_email',
'intentos_examen.numero_intento',
'intentos_examen.porcentaje',
'intentos_examen.aprobado',
'intentos_examen.tiempo_utilizado',
'intentos_examen.finalizado_en'
)
->orderBy('intentos_examen.porcentaje', 'desc')
->get();
// Estadísticas generales
$estadisticas = [
'total_estudiantes' => $resultados->groupBy('estudiante_id')->count(),
'promedio' => $resultados->avg('porcentaje'),
'aprobados' => $resultados->where('aprobado', true)->count(),
'reprobados' => $resultados->where('aprobado', false)->count(),
'tiempo_promedio' => $resultados->avg('tiempo_utilizado')
];
return response()->json([
'success' => true,
'data' => [
'resultados' => $resultados,
'estadisticas' => $estadisticas
]
]);
} catch (\Exception $e) {
Log::error('Error obteniendo resultados', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al cargar los resultados'
], 500);
}
}
}

@ -0,0 +1,295 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\Pregunta;
use App\Models\Curso;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class PreguntaController extends Controller
{
public function getPreguntasCurso($cursoId, Request $request)
{
try {
$query = Pregunta::where('curso_id', $cursoId);
if ($request->filled('nivel_dificultad')) {
$query->where('nivel_dificultad', $request->nivel_dificultad);
}
if ($request->filled('search')) {
$query->where('enunciado', 'like', '%' . $request->search . '%');
}
if ($request->filled('activo') && $request->activo !== '') {
$query->where('activo', $request->activo === 'true');
}
$preguntas = $query
->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 15));
$estadisticas = [
'total' => Pregunta::where('curso_id', $cursoId)->count(),
'facil' => Pregunta::where('curso_id', $cursoId)->where('nivel_dificultad', 'facil')->count(),
'medio' => Pregunta::where('curso_id', $cursoId)->where('nivel_dificultad', 'medio')->count(),
'dificil' => Pregunta::where('curso_id', $cursoId)->where('nivel_dificultad', 'dificil')->count(),
];
return response()->json([
'success' => true,
'data' => [
'preguntas' => $preguntas,
'estadisticas' => $estadisticas
]
]);
} catch (\Exception $e) {
Log::error('Error obteniendo preguntas', ['error' => $e->getMessage()]);
return response()->json([
'success' => false,
'message' => 'Error al cargar preguntas'
], 500);
}
}
public function getPregunta($id)
{
$pregunta = Pregunta::find($id);
if (!$pregunta) {
return response()->json([
'success' => false,
'message' => 'Pregunta no encontrada'
], 404);
}
return response()->json([
'success' => true,
'data' => $pregunta
]);
}
public function agregarPreguntaCurso(Request $request)
{
try {
$user = auth()->user();
if (!$user->hasRole('Admin')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$validator = Validator::make($request->all(), [
'curso_id' => 'required|exists:cursos,id',
'enunciado' => 'required|string',
'enunciado_adicional' => 'nullable|string',
'opciones' => 'required|array|min:2',
'opciones.*' => 'required|string',
'respuesta_correcta' => 'required|string',
'explicacion' => 'nullable|string',
'nivel_dificultad' => 'required|in:facil,medio,dificil',
'activo' => 'boolean',
'imagenes' => 'nullable|array',
'imagenes.*' => 'image|mimes:jpg,jpeg,png,gif,webp|max:2048',
'imagenes_explicacion' => 'nullable|array',
'imagenes_explicacion.*' => 'image|mimes:jpg,jpeg,png,gif,webp|max:2048',
], [
'opciones.required' => 'Debe agregar al menos 2 opciones',
'opciones.min' => 'Debe agregar al menos 2 opciones',
'respuesta_correcta.required' => 'Debe seleccionar una respuesta correcta',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors()
], 422);
}
// Validar que la respuesta correcta esté en las opciones
if (!in_array($request->respuesta_correcta, $request->opciones)) {
return response()->json([
'success' => false,
'errors' => ['respuesta_correcta' => ['La respuesta correcta debe estar entre las opciones']]
], 422);
}
// Procesar imágenes del enunciado
$imagenesPaths = [];
if ($request->hasFile('imagenes')) {
foreach ($request->file('imagenes') as $imagen) {
$path = $imagen->store('preguntas/enunciados', 'public');
$imagenesPaths[] = $path;
}
}
// Procesar imágenes de la explicación
$imagenesExplicacionPaths = [];
if ($request->hasFile('imagenes_explicacion')) {
foreach ($request->file('imagenes_explicacion') as $imagen) {
$path = $imagen->store('preguntas/explicaciones', 'public');
$imagenesExplicacionPaths[] = $path;
}
}
$pregunta = Pregunta::create([
'curso_id' => $request->curso_id,
'enunciado' => $request->enunciado,
'enunciado_adicional' => $request->enunciado_adicional,
'opciones' => $request->opciones,
'respuesta_correcta' => $request->respuesta_correcta,
'explicacion' => $request->explicacion,
'nivel_dificultad' => $request->nivel_dificultad,
'activo' => $request->boolean('activo'),
'imagenes' => $imagenesPaths,
'imagenes_explicacion' => $imagenesExplicacionPaths,
]);
Log::info('Pregunta creada', [
'pregunta_id' => $pregunta->id,
'curso_id' => $request->curso_id,
'user_id' => $user->id
]);
return response()->json([
'success' => true,
'message' => 'Pregunta creada correctamente',
'data' => $pregunta
], 201);
} catch (\Exception $e) {
Log::error('Error creando pregunta', ['error' => $e->getMessage()]);
return response()->json([
'success' => false,
'message' => 'Error al crear la pregunta'
], 500);
}
}
public function actualizarPregunta(Request $request, $id)
{
$pregunta = Pregunta::find($id);
if (!$pregunta) {
return response()->json([
'success' => false,
'message' => 'Pregunta no encontrada'
], 404);
}
$validator = Validator::make($request->all(), [
'enunciado' => 'required|string',
'enunciado_adicional' => 'nullable|string',
'opciones' => 'required|array|min:2',
'opciones.*' => 'required|string',
'respuesta_correcta' => 'required|string',
'explicacion' => 'nullable|string',
'nivel_dificultad' => 'required|in:facil,medio,dificil',
'activo' => 'boolean',
'imagenes' => 'nullable|array',
'imagenes.*' => 'image|mimes:jpg,jpeg,png,gif,webp|max:2048',
'imagenes_explicacion' => 'nullable|array',
'imagenes_explicacion.*' => 'image|mimes:jpg,jpeg,png,gif,webp|max:2048',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors()
], 422);
}
// Validar que la respuesta correcta esté en las opciones
if (!in_array($request->respuesta_correcta, $request->opciones)) {
return response()->json([
'success' => false,
'errors' => ['respuesta_correcta' => ['La respuesta correcta debe estar entre las opciones']]
], 422);
}
// Procesar nuevas imágenes del enunciado
$imagenesActuales = $pregunta->imagenes ?? [];
if ($request->hasFile('imagenes')) {
foreach ($request->file('imagenes') as $imagen) {
$path = $imagen->store('preguntas/enunciados', 'public');
$imagenesActuales[] = $path;
}
}
// Procesar nuevas imágenes de la explicación
$imagenesExplicacionActuales = $pregunta->imagenes_explicacion ?? [];
if ($request->hasFile('imagenes_explicacion')) {
foreach ($request->file('imagenes_explicacion') as $imagen) {
$path = $imagen->store('preguntas/explicaciones', 'public');
$imagenesExplicacionActuales[] = $path;
}
}
// Si se enviaron imágenes existentes en edición
if ($request->has('imagenes_existentes')) {
$imagenesActuales = $request->imagenes_existentes;
}
if ($request->has('imagenes_explicacion_existentes')) {
$imagenesExplicacionActuales = $request->imagenes_explicacion_existentes;
}
$pregunta->update([
'enunciado' => $request->enunciado,
'enunciado_adicional' => $request->enunciado_adicional,
'opciones' => $request->opciones,
'respuesta_correcta' => $request->respuesta_correcta,
'explicacion' => $request->explicacion,
'nivel_dificultad' => $request->nivel_dificultad,
'activo' => $request->boolean('activo'),
'imagenes' => $imagenesActuales,
'imagenes_explicacion' => $imagenesExplicacionActuales,
]);
return response()->json([
'success' => true,
'message' => 'Pregunta actualizada correctamente',
'data' => $pregunta
]);
}
public function eliminarPregunta($id)
{
$pregunta = Pregunta::find($id);
if (!$pregunta) {
return response()->json([
'success' => false,
'message' => 'Pregunta no encontrada'
], 404);
}
// Eliminar imágenes del storage si existen
if ($pregunta->imagenes) {
foreach ($pregunta->imagenes as $imagen) {
Storage::disk('public')->delete($imagen);
}
}
if ($pregunta->imagenes_explicacion) {
foreach ($pregunta->imagenes_explicacion as $imagen) {
Storage::disk('public')->delete($imagen);
}
}
$pregunta->delete();
return response()->json([
'success' => true,
'message' => 'Pregunta eliminada correctamente'
]);
}
}

@ -0,0 +1,165 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\Proceso;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class ProcesoController extends Controller
{
/* =============================
| LISTAR (INDEX)
============================= */
public function index(Request $request)
{
$query = Proceso::query();
// 🔍 Filtros
if ($request->filled('search')) {
$query->where('nombre', 'like', '%' . $request->search . '%');
}
if ($request->filled('activo')) {
$query->where('activo', $request->boolean('activo'));
}
if ($request->filled('publico')) {
$query->where('publico', $request->boolean('publico'));
}
if ($request->filled('tipo_proceso')) {
$query->where('tipo_proceso', $request->tipo_proceso);
}
// 📄 Paginación
$procesos = $query
->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 10));
return response()->json($procesos);
}
/* =============================
| CREAR (STORE)
============================= */
public function store(Request $request)
{
$data = $request->validate([
'nombre' => 'required|string|max:255',
'descripcion' => 'nullable|string',
'estado' => 'nullable|string|max:50',
'duracion' => 'nullable|integer|min:1',
'intentos_maximos' => 'nullable|integer|min:1',
'requiere_pago' => 'boolean',
'precio' => 'nullable|numeric|min:0',
'calificacion_id' => 'nullable|exists:calificaciones,id',
'tipo_simulacro' => 'nullable|string|max:50',
'tipo_proceso' => 'nullable|string|max:50',
'activo' => 'boolean',
'publico' => 'boolean',
'fecha_inicio' => 'nullable|date',
'fecha_fin' => 'nullable|date|after_or_equal:fecha_inicio',
'tiempo_por_pregunta' => 'nullable|integer|min:1',
]);
$data['slug'] = Str::slug($data['nombre']) . '-' . uniqid();
$proceso = Proceso::create($data);
return response()->json([
'message' => 'Proceso creado correctamente',
'data' => $proceso
], 201);
}
/* =============================
| VER (SHOW)
============================= */
public function show($id)
{
$proceso = Proceso::findOrFail($id);
return response()->json($proceso);
}
/* =============================
| ACTUALIZAR (UPDATE)
============================= */
public function update(Request $request, $id)
{
$proceso = Proceso::findOrFail($id);
$data = $request->validate([
'nombre' => 'required|string|max:255',
'descripcion' => 'nullable|string',
'estado' => 'nullable|string|max:50',
'duracion' => 'nullable|integer|min:1',
'intentos_maximos' => 'nullable|integer|min:1',
'requiere_pago' => 'boolean',
'precio' => 'nullable|numeric|min:0',
'calificacion_id' => 'nullable|exists:calificaciones,id',
'tipo_simulacro' => 'nullable|string|max:50',
'tipo_proceso' => 'nullable|string|max:50',
'activo' => 'boolean',
'publico' => 'boolean',
'fecha_inicio' => 'nullable|date',
'fecha_fin' => 'nullable|date|after_or_equal:fecha_inicio',
'tiempo_por_pregunta' => 'nullable|integer|min:1',
]);
// 🔄 Regenerar slug si cambia nombre
if ($data['nombre'] !== $proceso->nombre) {
$data['slug'] = Str::slug($data['nombre']) . '-' . uniqid();
}
$proceso->update($data);
return response()->json([
'message' => 'Proceso actualizado correctamente',
'data' => $proceso
]);
}
/* =============================
| ELIMINAR (DESTROY)
============================= */
public function destroy($id)
{
$proceso = Proceso::findOrFail($id);
$proceso->delete();
return response()->json([
'message' => 'Proceso eliminado correctamente'
]);
}
/* =============================
| TOGGLE ACTIVO
============================= */
public function toggleActivo($id)
{
$proceso = Proceso::findOrFail($id);
$proceso->activo = !$proceso->activo;
$proceso->save();
return response()->json([
'message' => 'Estado actualizado',
'activo' => $proceso->activo
]);
}
}

@ -0,0 +1,94 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Area extends Model
{
use HasFactory;
protected $table = 'areas';
protected $fillable = [
'nombre',
'codigo',
'activo',
];
protected $casts = [
'activo' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
/* ================= RELACIONES ================= */
public function cursos()
{
return $this->belongsToMany(Curso::class, 'area_curso');
}
public function examenes()
{
return $this->belongsToMany(
Examen::class,
'examen_area',
'area_id',
'examen_id'
)->withTimestamps();
}
/* ================= SCOPES ================= */
public function scopeActivas($query)
{
return $query->where('activo', true);
}
public function scopeInactivas($query)
{
return $query->where('activo', false);
}
public function scopePorCodigo($query, $codigo)
{
return $query->where('codigo', strtoupper($codigo));
}
public function scopePorNombre($query, $nombre)
{
return $query->where('nombre', 'like', "%{$nombre}%");
}
public function scopeDeAcademia($query, $academiaId)
{
return $query->where('academia_id', $academiaId);
}
/* ================= ACCESSORS ================= */
public function getEstadoAttribute()
{
return $this->activo ? 'Activo' : 'Inactivo';
}
public function getEstadisticasAttribute()
{
return [
'total_cursos' => $this->cursos()->count(),
'total_examenes' => $this->examenes()->count(),
];
}
public function getCursosCountAttribute()
{
return $this->cursos()->count();
}
public function getExamenesCountAttribute()
{
return $this->examenes()->count();
}
}

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Curso extends Model
{
use HasFactory;
protected $table = 'cursos';
protected $fillable = [
'nombre',
'codigo',
'activo',
];
protected $casts = [
'activo' => 'boolean',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function areas()
{
return $this->belongsToMany(Area::class, 'area_curso');
}
// Curso → Preguntas
public function preguntas()
{
return $this->hasMany(Pregunta::class);
}
}

@ -0,0 +1,60 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Pregunta extends Model
{
use HasFactory;
protected $table = 'preguntas';
protected $fillable = [
'curso_id',
'enunciado',
'imagenes',
'enunciado_adicional',
'opciones',
'respuesta_correcta',
'explicacion',
'imagenes_explicacion',
'nivel_dificultad',
'activo',
];
protected $casts = [
'opciones' => 'array',
'imagenes' => 'array',
'imagenes_explicacion' => 'array',
'activo' => 'boolean',
];
public function curso()
{
return $this->belongsTo(Curso::class);
}
public function scopeActivas($query)
{
return $query->where('activo', true);
}
public function scopeDeCurso($query, $cursoId)
{
return $query->where('curso_id', $cursoId);
}
public function scopeBuscar($query, $texto)
{
return $query->where(function ($q) use ($texto) {
$q->where('enunciado', 'like', "%{$texto}%")
->orWhere('enunciado_adicional', 'like', "%{$texto}%")
->orWhere('explicacion', 'like', "%{$texto}%");
});
}
}

@ -0,0 +1,115 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class Proceso extends Model
{
use HasFactory;
protected $table = 'procesos';
protected $fillable = [
'nombre',
'descripcion',
'estado',
'duracion',
'intentos_maximos',
'requiere_pago',
'precio',
'calificacion_id',
'slug',
'tipo_simulacro',
'tipo_proceso',
'activo',
'publico',
'fecha_inicio',
'fecha_fin',
'tiempo_por_pregunta',
];
protected $casts = [
'requiere_pago' => 'boolean',
'activo' => 'boolean',
'publico' => 'boolean',
'duracion' => 'integer',
'intentos_maximos' => 'integer',
'tiempo_por_pregunta' => 'integer',
'precio' => 'decimal:2',
'fecha_inicio' => 'datetime',
'fecha_fin' => 'datetime',
];
// Examen → Calificación (opcional)
public function calificacion()
{
return $this->belongsTo(Calificacion::class);
}
/* =============================
| SCOPES
============================= */
public function scopeActivos($query)
{
return $query->where('activo', true);
}
public function scopePublicos($query)
{
return $query->where('publico', true);
}
public function scopePorProceso($query, $proceso)
{
return $query->where('tipo_proceso', $proceso);
}
/* =============================
| ACCESSORS
============================= */
public function getEstaDisponibleAttribute(): bool
{
$now = now();
if (!$this->activo) {
return false;
}
if ($this->fecha_inicio && $now->lt($this->fecha_inicio)) {
return false;
}
if ($this->fecha_fin && $now->gt($this->fecha_fin)) {
return false;
}
return true;
}
/* =============================
| EVENTS
============================= */
protected static function booted()
{
static::creating(function ($examen) {
if (empty($examen->slug)) {
$examen->slug = Str::slug($examen->nombre) . '-' . uniqid();
}
});
}
}

@ -5,30 +5,69 @@ use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\AcademiaController;
use App\Http\Controllers\VinculacionController;
use App\Http\Controllers\Administracion\AdminAcademiaController;
use App\Http\Controllers\Administracion\administradorController;
use App\Http\Controllers\Administracion\ExamenesController;
use App\Http\Controllers\Administracion\PreguntasController;
use App\Http\Controllers\Administracion\AreaController;
use App\Http\Controllers\SuperAdminController;
use App\Http\Controllers\Administracion\CursoController;
use App\Http\Controllers\Administracion\PreguntaController;
use App\Http\Controllers\Administracion\ProcesoController;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
// Rutas protegidas
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/me', [AuthController::class, 'me']);
Route::post('/refresh-token', [AuthController::class, 'refresh']);
});
Route::middleware(['auth:sanctum'])->prefix('procesos')->group(function () {
Route::get('/', [ProcesoController::class, 'index']);
Route::post('/', [ProcesoController::class, 'store']);
Route::get('{id}', [ProcesoController::class, 'show']);
Route::put('{id}', [ProcesoController::class, 'update']);
Route::delete('{id}', [ProcesoController::class, 'destroy']);
Route::patch('{id}/toggle-activo', [ProcesoController::class, 'toggleActivo']);
});
Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
Route::get('/areas', [AreaController::class, 'index']);
Route::post('/areas', [AreaController::class, 'store']);
Route::get('/areas/{id}', [AreaController::class, 'show']);
Route::put('/areas/{id}', [AreaController::class, 'update']);
Route::delete('/areas/{id}', [AreaController::class, 'destroy']);
Route::patch('/areas/{id}/toggle', [AreaController::class, 'toggleEstado']);
});
Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
Route::get('/cursos', [CursoController::class, 'index']);
Route::post('/cursos', [CursoController::class, 'store']);
Route::get('/cursos/{id}', [CursoController::class, 'show']);
Route::put('/cursos/{id}', [CursoController::class, 'update']);
Route::delete('/cursos/{id}', [CursoController::class, 'destroy']);
Route::patch('/cursos/{id}/toggle', [CursoController::class, 'toggleEstado']);
});
Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
Route::get('cursos/{cursoId}/preguntas', [PreguntaController::class, 'getPreguntasCurso']);
Route::post('preguntas', [PreguntaController::class, 'agregarPreguntaCurso']);
Route::get('preguntas/{id}', [PreguntaController::class, 'getPregunta'] );
Route::put('preguntas/{id}', [PreguntaController::class, 'actualizarPregunta']);
Route::delete('preguntas/{id}', [PreguntaController::class, 'eliminarPregunta'] );
});
});

@ -229,8 +229,8 @@ onMounted(() => {
const cargarAdministradores = async () => {
try {
loadingAdmins.value = true
// Aquí deberías implementar un endpoint para obtener usuarios con rol AdminAcademia
const response = await api.get('/usuarios?role=AdminAcademia')
// Aquí deberías implementar un endpoint para obtener usuarios con rol administrador
const response = await api.get('/usuarios?role=administrador')
administradores.value = response.data
} catch (error) {
message.error('Error al cargar administradores')

@ -20,13 +20,51 @@ const routes = [
component: () => import('../views/usuario/Dashboard.vue'),
meta: { requiresAuth: true, role: 'usuario' }
},
{
path: '/admin/dashboard',
name: 'admin-dashboard',
component: () => import('../views/administrador/Dashboard.vue'),
meta: { requiresAuth: true, role: 'administrador' }
component: () => import('../views/administrador/layout/Layout.vue'),
meta: { requiresAuth: true, role: 'administrador' },
children: [
{
path: '/admin/dashboard',
name: 'Dashboard',
component: () => import('../views/administrador/Dashboard.vue'),
meta: { requiresAuth: true, role: 'administrador' }
},
{
path: '/admin/dashboard/areas',
name: 'Areas',
component: () => import('../views/administrador/areas/AreasList.vue'),
meta: { requiresAuth: true, role: 'administrador' }
},
{
path: '/admin/dashboard/cursos',
name: 'Cursos',
component: () => import('../views/administrador/cursos/CursosList.vue'),
meta: { requiresAuth: true, role: 'administrador' }
},
{
path: '/admin/dashboard/cursos/:id/preguntas',
name: 'CursoPreguntas',
component: () => import('../views/administrador/cursos/PreguntasCursoView.vue'),
meta: { requiresAuth: true }
},
{
path: '/admin/dashboard/procesos',
name: 'Procesos',
component: () => import('../views/administrador/Procesos/ProcesosList.vue'),
meta: { requiresAuth: true }
}
]
},
{
path: '/superadmin/dashboard',
name: 'superadmin-dashboard',

@ -0,0 +1,189 @@
import { defineStore } from 'pinia'
import api from '../axios'
export const useAreaStore = defineStore('area', {
state: () => ({
areas: [],
area: null,
// paginación
pagination: {
current_page: 1,
per_page: 10,
total: 0,
},
// filtros
filters: {
search: '',
activo: null, // true | false | null
},
loading: false,
errors: null,
}),
actions: {
/* =============================
* LISTAR ÁREAS (con filtros)
* GET /api/admin/areas
* ============================= */
async fetchAreas(params = {}) {
this.loading = true
this.errors = null
try {
const res = await api.get('/admin/areas', {
params: {
page: params.page ?? this.pagination.current_page,
per_page: params.per_page ?? this.pagination.per_page,
search: params.search ?? this.filters.search,
activo: params.activo ?? this.filters.activo,
},
})
this.areas = res.data.data.data
this.pagination.current_page = res.data.data.current_page
this.pagination.per_page = res.data.data.per_page
this.pagination.total = res.data.data.total
} catch (error) {
console.error(error)
} finally {
this.loading = false
}
},
/* =============================
* MOSTRAR ÁREA
* GET /api/admin/areas/{id}
* ============================= */
async fetchArea(id) {
this.loading = true
this.errors = null
try {
const res = await api.get(`/admin/areas/${id}`)
this.area = res.data.data
} catch (error) {
console.error(error)
} finally {
this.loading = false
}
},
/* =============================
* CREAR ÁREA
* POST /api/admin/areas
* ============================= */
async createArea(payload) {
this.loading = true
this.errors = null
try {
await api.post('/admin/areas', payload)
await this.fetchAreas({ page: 1 })
return true
} catch (error) {
if (error.response?.status === 422) {
this.errors = error.response.data.errors
}
return false
} finally {
this.loading = false
}
},
/* =============================
* ACTUALIZAR ÁREA
* PUT /api/admin/areas/{id}
* ============================= */
async updateArea(id, payload) {
this.loading = true
this.errors = null
try {
await api.put(`/admin/areas/${id}`, payload)
await this.fetchAreas()
return true
} catch (error) {
if (error.response?.status === 422) {
this.errors = error.response.data.errors
}
return false
} finally {
this.loading = false
}
},
/* =============================
* ACTIVAR / DESACTIVAR ÁREA
* PATCH /api/admin/areas/{id}/toggle
* ============================= */
async toggleArea(id) {
this.loading = true
this.errors = null
try {
const res = await api.patch(`/admin/areas/${id}/toggle`)
// actualizar lista
const index = this.areas.findIndex(a => a.id === id)
if (index !== -1) {
this.areas[index].activo = res.data.data.activo
}
// actualizar área actual si se está viendo
if (this.area?.id === id) {
this.area.activo = res.data.data.activo
}
return res.data
} catch (error) {
console.error(error)
if (error.response) this.errors = error.response.data
return null
} finally {
this.loading = false
}
},
/* =============================
* ELIMINAR ÁREA
* DELETE /api/admin/areas/{id}
* ============================= */
async deleteArea(id) {
this.loading = true
try {
await api.delete(`/admin/areas/${id}`)
this.areas = this.areas.filter(a => a.id !== id)
this.pagination.total--
} catch (error) {
console.error(error)
} finally {
this.loading = false
}
},
/* =============================
* SETTERS DE FILTROS
* ============================= */
setSearch(search) {
this.filters.search = search
},
setActivo(activo) {
this.filters.activo = activo
},
clearFilters() {
this.filters.search = ''
this.filters.activo = null
},
clearErrors() {
this.errors = null
},
},
})

@ -0,0 +1,118 @@
import { defineStore } from 'pinia'
import api from '../axios'
export const useCursoStore = defineStore('curso', {
state: () => ({
cursos: [],
curso: null,
loading: false,
errors: null,
search: '',
activo: null,
pagination: {
current_page: 1,
per_page: 10,
total: 0
}
}),
actions: {
setSearch(value) {
this.search = value
},
setActivo(value) {
this.activo = value
},
clearFilters() {
this.search = ''
this.activo = null
},
async fetchCursos({ page = 1, per_page = this.pagination.per_page } = {}) {
this.loading = true
try {
const params = { page, per_page }
if (this.search) params.search = this.search
if (this.activo !== null) params.activo = this.activo
const res = await api.get('/admin/cursos', { params })
this.cursos = res.data.data.data
this.pagination = {
current_page: res.data.data.current_page,
per_page: res.data.data.per_page,
total: res.data.data.total
}
} catch (error) {
console.error(error)
} finally {
this.loading = false
}
},
async createCurso(payload) {
this.loading = true
this.errors = null
try {
const res = await api.post('/admin/cursos', payload)
this.cursos.unshift(res.data.data)
return true
} catch (error) {
if (error.response) this.errors = error.response.data.errors
return false
} finally {
this.loading = false
}
},
async updateCurso(id, payload) {
this.loading = true
this.errors = null
try {
const res = await api.put(`/admin/cursos/${id}`, payload)
const index = this.cursos.findIndex(c => c.id === id)
if (index !== -1) this.cursos[index] = res.data.data
return true
} catch (error) {
if (error.response) this.errors = error.response.data.errors
return false
} finally {
this.loading = false
}
},
async deleteCurso(id) {
this.loading = true
try {
await api.delete(`/admin/cursos/${id}`)
this.cursos = this.cursos.filter(c => c.id !== id)
return true
} catch (error) {
console.error(error)
return false
} finally {
this.loading = false
}
},
async toggleCurso(id) {
this.loading = true
try {
const res = await api.patch(`/admin/cursos/${id}/toggle`)
const index = this.cursos.findIndex(c => c.id === id)
if (index !== -1) this.cursos[index].activo = res.data.data.activo
if (this.curso?.id === id) this.curso.activo = res.data.data.activo
return res.data
} catch (error) {
console.error(error)
if (error.response) this.errors = error.response.data
return null
} finally {
this.loading = false
}
}
}
})

@ -0,0 +1,199 @@
// store/pregunta.store.js
import { defineStore } from 'pinia'
import api from '../axios'
export const usePreguntaStore = defineStore('pregunta', {
state: () => ({
preguntas: [],
pregunta: null,
loading: false,
errors: null,
}),
actions: {
/* ===============================
OBTENER PREGUNTAS POR CURSO
=============================== */
async fetchPreguntasByCurso(cursoId, params = {}) {
this.loading = true
this.errors = null
try {
const res = await api.get(
`/admin/cursos/${cursoId}/preguntas`,
{ params }
)
this.preguntas = res.data.data.preguntas.data || []
return res.data.data
} catch (error) {
this.errors = error.response?.data || error.message
return null
} finally {
this.loading = false
}
},
/* ===============================
OBTENER UNA PREGUNTA
=============================== */
async fetchPregunta(id) {
this.loading = true
this.errors = null
try {
const res = await api.get(`/admin/preguntas/${id}`)
this.pregunta = res.data.data
return res.data.data
} catch (error) {
this.errors = error.response?.data || error.message
return null
} finally {
this.loading = false
}
},
/* ===============================
CREAR PREGUNTA (CON IMÁGENES)
=============================== */
async crearPregunta(data) {
this.loading = true
this.errors = null
try {
const formData = new FormData()
// Campos simples
formData.append('curso_id', data.curso_id)
formData.append('enunciado', data.enunciado)
formData.append('nivel_dificultad', data.nivel_dificultad)
if (data.enunciado_adicional)
formData.append('enunciado_adicional', data.enunciado_adicional)
if (data.respuesta_correcta)
formData.append('respuesta_correcta', data.respuesta_correcta)
if (data.explicacion)
formData.append('explicacion', data.explicacion)
if (data.opciones)
formData.append('opciones', JSON.stringify(data.opciones))
// Imágenes del enunciado
if (data.imagenes?.length) {
data.imagenes.forEach(img => {
formData.append('imagenes[]', img)
})
}
// Imágenes de la explicación
if (data.imagenes_explicacion?.length) {
data.imagenes_explicacion.forEach(img => {
formData.append('imagenes_explicacion[]', img)
})
}
const res = await api.post('/admin/preguntas', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
this.preguntas.unshift(res.data.data)
return res.data.data
} catch (error) {
this.errors =
error.response?.status === 422
? error.response.data.errors
: error.response?.data || error.message
throw error
} finally {
this.loading = false
}
},
/* ===============================
ACTUALIZAR PREGUNTA (SUMA IMÁGENES)
=============================== */
async actualizarPregunta(id, data) {
this.loading = true
this.errors = null
try {
const formData = new FormData()
formData.append('enunciado', data.enunciado)
formData.append('nivel_dificultad', data.nivel_dificultad)
formData.append('activo', data.activo ? 1 : 0)
if (data.enunciado_adicional)
formData.append('enunciado_adicional', data.enunciado_adicional)
if (data.respuesta_correcta)
formData.append('respuesta_correcta', data.respuesta_correcta)
if (data.explicacion)
formData.append('explicacion', data.explicacion)
if (data.opciones)
formData.append('opciones', JSON.stringify(data.opciones))
if (data.imagenes?.length) {
data.imagenes.forEach(img => {
formData.append('imagenes[]', img)
})
}
if (data.imagenes_explicacion?.length) {
data.imagenes_explicacion.forEach(img => {
formData.append('imagenes_explicacion[]', img)
})
}
formData.append('_method', 'PUT')
const res = await api.post(`/admin/preguntas/${id}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const index = this.preguntas.findIndex(p => p.id === id)
if (index !== -1) this.preguntas[index] = res.data.data
if (this.pregunta?.id === id) this.pregunta = res.data.data
return res.data.data
} catch (error) {
this.errors =
error.response?.status === 422
? error.response.data.errors
: error.response?.data || error.message
throw error
} finally {
this.loading = false
}
},
/* ===============================
ELIMINAR PREGUNTA
=============================== */
async eliminarPregunta(id) {
this.loading = true
this.errors = null
try {
await api.delete(`/admin/preguntas/${id}`)
this.preguntas = this.preguntas.filter(p => p.id !== id)
return true
} catch (error) {
this.errors = error.response?.data || error.message
throw error
} finally {
this.loading = false
}
},
clearPregunta() {
this.pregunta = null
this.errors = null
},
},
})

@ -0,0 +1,178 @@
import { defineStore } from 'pinia'
import api from '../axios'
export const useProcesoStore = defineStore('proceso', {
state: () => ({
procesos: [],
proceso: null,
loading: false,
errors: null,
pagination: {
current: 1,
perPage: 10,
total: 0,
},
}),
actions: {
/* =============================
| LISTAR
============================= */
async fetchProcesos(params = {}) {
this.loading = true
this.errors = null
try {
const res = await api.get('/procesos', {
params: {
page: this.pagination.current,
per_page: this.pagination.perPage,
...params,
},
})
this.procesos = res.data.data
this.pagination.total = res.data.total
this.pagination.current = res.data.current_page
this.pagination.perPage = res.data.per_page
return res.data
} catch (error) {
this.errors = error.response?.data || error.message
throw error
} finally {
this.loading = false
}
},
/* =============================
| VER
============================= */
async fetchProceso(id) {
this.loading = true
this.errors = null
try {
const res = await api.get(`/procesos/${id}`)
this.proceso = res.data
return res.data
} catch (error) {
this.errors = error.response?.data || error.message
throw error
} finally {
this.loading = false
}
},
/* =============================
| CREAR
============================= */
async crearProceso(payload) {
this.loading = true
this.errors = null
try {
const res = await api.post('/procesos', payload)
this.procesos.unshift(res.data.data)
return res.data.data
} catch (error) {
if (error.response?.status === 422) {
this.errors = error.response.data.errors
} else {
this.errors = error.response?.data || error.message
}
throw error
} finally {
this.loading = false
}
},
/* =============================
| ACTUALIZAR
============================= */
async actualizarProceso(id, payload) {
this.loading = true
this.errors = null
try {
const res = await api.put(`/procesos/${id}`, payload)
const index = this.procesos.findIndex(p => p.id === id)
if (index !== -1) {
this.procesos[index] = res.data.data
}
if (this.proceso?.id === id) {
this.proceso = res.data.data
}
return res.data.data
} catch (error) {
if (error.response?.status === 422) {
this.errors = error.response.data.errors
} else {
this.errors = error.response?.data || error.message
}
throw error
} finally {
this.loading = false
}
},
/* =============================
| ELIMINAR
============================= */
async eliminarProceso(id) {
this.loading = true
this.errors = null
try {
await api.delete(`/procesos/${id}`)
this.procesos = this.procesos.filter(p => p.id !== id)
return true
} catch (error) {
this.errors = error.response?.data || error.message
throw error
} finally {
this.loading = false
}
},
/* =============================
| TOGGLE ACTIVO
============================= */
async toggleActivo(id) {
this.loading = true
this.errors = null
try {
const res = await api.patch(`/procesos/${id}/toggle-activo`)
const index = this.procesos.findIndex(p => p.id === id)
if (index !== -1) {
this.procesos[index].activo = res.data.activo
}
if (this.proceso?.id === id) {
this.proceso.activo = res.data.activo
}
return res.data.activo
} catch (error) {
this.errors = error.response?.data || error.message
throw error
} finally {
this.loading = false
}
},
/* =============================
| LIMPIAR
============================= */
clearProceso() {
this.proceso = null
this.errors = null
},
},
})

File diff suppressed because it is too large Load Diff

@ -0,0 +1,489 @@
<template>
<div class="areas-container">
<!-- Header -->
<div class="areas-header">
<div class="header-title">
<h2>Procesos</h2>
<p class="subtitle">Gestión de procesos académicos</p>
</div>
<a-button type="primary" @click="showCreateModal">
<template #icon><PlusOutlined /></template>
Nuevo Proceso
</a-button>
</div>
<!-- Filtros -->
<div class="filters-section">
<a-input-search
v-model:value="searchText"
placeholder="Buscar por nombre..."
@search="handleSearch"
style="width: 300px"
size="large"
/>
<a-select
v-model:value="estadoFilter"
placeholder="Estado"
style="width: 200px; margin-left: 16px"
size="large"
@change="handleFilterChange"
>
<a-select-option :value="null">Todos</a-select-option>
<a-select-option :value="true">Activo</a-select-option>
<a-select-option :value="false">Inactivo</a-select-option>
</a-select>
<a-button
size="large"
style="margin-left: 16px"
@click="clearFilters"
>
<ReloadOutlined /> Limpiar
</a-button>
</div>
<!-- Tabla -->
<a-table
:data-source="procesoStore.procesos"
:columns="columns"
:loading="procesoStore.loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<!-- Estado -->
<template v-if="column.key === 'activo'">
<a-switch
:checked="record.activo"
@change="() => toggleActivo(record)"
checked-children="Activo"
un-checked-children="Inactivo"
/>
</template>
<!-- Público -->
<template v-if="column.key === 'publico'">
<a-tag :color="record.publico ? 'green' : 'default'">
{{ record.publico ? 'Público' : 'Privado' }}
</a-tag>
</template>
<!-- Fecha -->
<template v-if="column.key === 'created_at'">
{{ formatDate(record.created_at) }}
</template>
<!-- Acciones -->
<template v-if="column.key === 'acciones'">
<a-space>
<a-button type="link" @click="showEditModal(record)">
<EditOutlined /> Editar
</a-button>
<a-button danger type="link" @click="confirmDelete(record)">
<DeleteOutlined /> Eliminar
</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- Modal Crear / Editar -->
<a-modal
v-model:open="modalVisible"
:title="isEditing ? 'Editar Proceso' : 'Nuevo Proceso'"
:confirm-loading="procesoStore.loading"
@ok="handleSubmit"
@cancel="closeModal"
width="600px"
>
<a-form ref="formRef" :model="formState" layout="vertical">
<a-form-item label="Nombre" required>
<a-input v-model:value="formState.nombre" />
</a-form-item>
<a-form-item label="Descripción">
<a-textarea v-model:value="formState.descripcion" />
</a-form-item>
<a-form-item label="Tipo de Proceso">
<a-input v-model:value="formState.tipo_proceso" />
</a-form-item>
<a-form-item label="Tipo de Simulacro">
<a-input v-model:value="formState.tipo_simulacro" />
</a-form-item>
<a-form-item label="Duración (min)">
<a-input-number v-model:value="formState.duracion" style="width:100%" />
</a-form-item>
<a-form-item label="Intentos Máximos">
<a-input-number v-model:value="formState.intentos_maximos" style="width:100%" />
</a-form-item>
<a-form-item label="Público">
<a-switch v-model:checked="formState.publico" />
</a-form-item>
<a-form-item label="Activo">
<a-switch v-model:checked="formState.activo" />
</a-form-item>
<!-- Errores -->
<a-alert
v-if="procesoStore.errors"
type="error"
show-icon
:message="'Errores en el formulario'"
/>
</a-form>
</a-modal>
<!-- Modal Eliminar -->
<a-modal
v-model:open="deleteModalVisible"
title="Eliminar Proceso"
ok-type="danger"
ok-text="Eliminar"
@ok="handleDelete"
@cancel="deleteModalVisible = false"
>
<a-alert
type="warning"
show-icon
message="¿Deseas eliminar este proceso?"
/>
<p><strong>{{ procesoToDelete?.nombre }}</strong></p>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useProcesoStore } from '../../../store/proceso.store'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ReloadOutlined
} from '@ant-design/icons-vue'
const procesoStore = useProcesoStore()
const modalVisible = ref(false)
const deleteModalVisible = ref(false)
const isEditing = ref(false)
const procesoToDelete = ref(null)
const formRef = ref()
const searchText = ref('')
const estadoFilter = ref(null)
const formState = reactive({
id: null,
nombre: '',
descripcion: '',
tipo_proceso: '',
tipo_simulacro: '',
duracion: null,
intentos_maximos: null,
publico: false,
activo: true,
})
const pagination = computed(() => ({
current: procesoStore.pagination.current,
pageSize: procesoStore.pagination.perPage,
total: procesoStore.pagination.total,
showSizeChanger: true,
}))
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: 'Nombre', dataIndex: 'nombre', key: 'nombre' },
{ title: 'Tipo Proceso', dataIndex: 'tipo_proceso', key: 'tipo_proceso' },
{ title: 'Público', dataIndex: 'publico', key: 'publico', width: 100 },
{ title: 'Activo', dataIndex: 'activo', key: 'activo', width: 100 },
{ title: 'Creado', dataIndex: 'created_at', key: 'created_at', width: 150 },
{ title: 'Acciones', key: 'acciones', width: 180, align: 'center' },
]
const showCreateModal = () => {
isEditing.value = false
resetForm()
modalVisible.value = true
}
const showEditModal = (proceso) => {
isEditing.value = true
Object.assign(formState, proceso)
modalVisible.value = true
}
const closeModal = () => {
modalVisible.value = false
resetForm()
}
const resetForm = () => {
Object.assign(formState, {
id: null,
nombre: '',
descripcion: '',
tipo_proceso: '',
tipo_simulacro: '',
duracion: null,
intentos_maximos: null,
publico: false,
activo: true,
})
}
const handleSubmit = async () => {
try {
if (isEditing.value) {
await procesoStore.actualizarProceso(formState.id, formState)
message.success('Proceso actualizado')
} else {
await procesoStore.crearProceso(formState)
message.success('Proceso creado')
}
closeModal()
procesoStore.fetchProcesos()
} catch {
message.error('Error al guardar')
}
}
const confirmDelete = (proceso) => {
procesoToDelete.value = proceso
deleteModalVisible.value = true
}
const handleDelete = async () => {
await procesoStore.eliminarProceso(procesoToDelete.value.id)
message.success('Proceso eliminado')
deleteModalVisible.value = false
}
const toggleActivo = async (proceso) => {
await procesoStore.toggleActivo(proceso.id)
}
const handleSearch = () => {
procesoStore.fetchProcesos({ search: searchText.value })
}
const handleFilterChange = () => {
procesoStore.fetchProcesos({ activo: estadoFilter.value })
}
const clearFilters = () => {
searchText.value = ''
estadoFilter.value = null
procesoStore.fetchProcesos()
}
const handleTableChange = (pagination) => {
procesoStore.pagination.current = pagination.current
procesoStore.pagination.perPage = pagination.pageSize
procesoStore.fetchProcesos()
}
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleDateString('es-ES')
}
onMounted(() => {
procesoStore.fetchProcesos()
})
</script>
<style scoped>
.areas-container {
padding: 0;
}
.areas-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 0 8px;
}
.header-title h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f1f1f;
}
.subtitle {
margin: 4px 0 0 0;
color: #666;
font-size: 14px;
}
.new-area-btn {
height: 40px;
font-weight: 500;
}
.filters-section {
display: flex;
align-items: center;
margin-bottom: 24px;
padding: 0 8px;
flex-wrap: wrap;
gap: 16px;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 16px;
}
.loading-state p {
color: #666;
margin: 0;
}
.empty-state {
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.areas-table-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
overflow: hidden;
}
.areas-table {
border-radius: 12px;
}
.areas-table :deep(.ant-table) {
border-radius: 12px;
}
.areas-table :deep(.ant-table-thead > tr > th) {
background: #fafafa;
font-weight: 600;
color: #1f1f1f;
border-bottom: 1px solid #f0f0f0;
}
.areas-table :deep(.ant-table-tbody > tr > td) {
border-bottom: 1px solid #f0f0f0;
}
.areas-table :deep(.ant-table-tbody > tr:hover > td) {
background: #fafafa;
}
.action-btn {
padding: 4px 8px;
height: auto;
}
.action-btn :deep(.anticon) {
font-size: 14px;
}
.area-modal :deep(.ant-modal-header) {
border-bottom: 1px solid #f0f0f0;
padding: 20px 24px;
}
.area-modal :deep(.ant-modal-body) {
padding: 24px;
}
.area-modal :deep(.ant-modal-footer) {
border-top: 1px solid #f0f0f0;
padding: 12px 24px;
}
.form-footer {
margin-top: 16px;
}
.error-alert {
margin-bottom: 8px;
}
.error-alert:last-child {
margin-bottom: 0;
}
.delete-confirm-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.area-info {
background: #fafafa;
padding: 12px;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.area-info p {
margin: 4px 0;
color: #666;
}
.area-info p strong {
color: #1f1f1f;
}
/* Responsive */
@media (max-width: 768px) {
.areas-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.new-area-btn {
align-self: flex-start;
}
.filters-section {
flex-direction: column;
align-items: stretch;
}
.filters-section .ant-input-search,
.filters-section .ant-select,
.filters-section .ant-btn {
width: 100%;
margin-left: 0;
margin-bottom: 8px;
}
.areas-table-container {
overflow-x: auto;
}
.areas-table {
min-width: 800px;
}
}
</style>

@ -0,0 +1,641 @@
<!-- components/AdminAcademia/Areas/AreasList.vue -->
<template>
<div class="areas-container">
<!-- Header con Título y Botón de Nueva Área -->
<div class="areas-header">
<div class="header-title">
<h2>Áreas Académicas</h2>
<p class="subtitle">Gestiona las áreas académicas del sistema</p>
</div>
<a-button
type="primary"
@click="showCreateModal"
class="new-area-btn"
>
<template #icon>
<PlusOutlined />
</template>
Nueva Área
</a-button>
</div>
<!-- Filtros -->
<div class="filters-section">
<a-input-search
v-model:value="searchText"
placeholder="Buscar por nombre o código..."
@search="handleSearch"
@press-enter="handleSearch"
style="width: 300px"
size="large"
>
<template #enterButton>
<SearchOutlined />
</template>
</a-input-search>
<a-select
v-model:value="statusFilter"
placeholder="Filtrar por estado"
style="width: 200px; margin-left: 16px"
size="large"
@change="handleStatusFilterChange"
>
<a-select-option :value="null">Todos los estados</a-select-option>
<a-select-option :value="true">Activo</a-select-option>
<a-select-option :value="false">Inactivo</a-select-option>
</a-select>
<a-button
@click="handleClearFilters"
style="margin-left: 16px"
size="large"
>
<template #icon>
<ReloadOutlined />
</template>
Limpiar filtros
</a-button>
</div>
<!-- Estado de Carga -->
<div v-if="areaStore.loading && areaStore.areas.length === 0" class="loading-state">
<a-spin size="large" />
<p>Cargando áreas...</p>
</div>
<!-- Estado Vacío -->
<div v-else-if="areaStore.areas.length === 0" class="empty-state">
<a-empty description="No hay áreas registradas" />
</div>
<!-- Tabla de Áreas -->
<div v-else class="areas-table-container">
<a-table
:data-source="areaStore.areas"
:columns="columns"
:loading="areaStore.loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
class="areas-table"
>
<template #bodyCell="{ column, record }">
<!-- Estado -->
<template v-if="column.key === 'activo'">
<a-switch
:checked="record.activo"
:loading="togglingId === record.id"
@change="handleToggleStatus(record)"
checked-children="Activo"
un-checked-children="Inactivo"
/>
</template>
<!-- Fecha Creación -->
<template v-if="column.key === 'created_at'">
{{ formatDate(record.created_at) }}
</template>
<!-- Acciones -->
<template v-if="column.key === 'acciones'">
<a-space>
<a-button
type="link"
size="small"
@click="showEditModal(record)"
class="action-btn"
>
<EditOutlined /> Editar
</a-button>
<a-button
type="link"
size="small"
danger
@click="confirmDelete(record)"
class="action-btn"
>
<DeleteOutlined /> Eliminar
</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- Modal Crear/Editar Área -->
<a-modal
v-model:open="modalVisible"
:title="isEditing ? 'Editar Área' : 'Nueva Área'"
:confirm-loading="areaStore.loading"
@ok="handleModalOk"
@cancel="handleModalCancel"
width="500px"
class="area-modal"
>
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
layout="vertical"
@finish="onFormSubmit"
>
<a-form-item
label="Nombre del Área"
name="nombre"
:validate-status="getFieldStatus('nombre')"
:help="getFieldHelp('nombre')"
>
<a-input
v-model:value="formState.nombre"
placeholder="Ej: Matemáticas, Ciencias, Lenguaje"
size="large"
/>
</a-form-item>
<a-form-item
label="Código"
name="codigo"
:validate-status="getFieldStatus('codigo')"
:help="getFieldHelp('codigo')"
>
<a-input
v-model:value="formState.codigo"
placeholder="Código único (ej: MAT, CIE, LEN)"
size="large"
/>
</a-form-item>
<div class="form-footer" v-if="areaStore.errors">
<a-alert
v-for="(errorList, field) in areaStore.errors"
:key="field"
type="error"
:message="errorList[0]"
show-icon
class="error-alert"
/>
</div>
</a-form>
</a-modal>
<!-- Modal de Confirmación para Eliminar -->
<a-modal
v-model:open="deleteModalVisible"
title="Confirmar Eliminación"
@ok="handleDelete"
@cancel="deleteModalVisible = false"
ok-text="Eliminar"
cancel-text="Cancelar"
ok-type="danger"
width="400px"
>
<div class="delete-confirm-content">
<a-alert
message="¿Estás seguro de eliminar esta área?"
description="Esta acción no se puede deshacer. Se eliminarán todos los datos asociados al área."
type="warning"
show-icon
/>
<div class="area-info" v-if="areaToDelete">
<p><strong>Área:</strong> {{ areaToDelete.nombre }}</p>
<p><strong>Código:</strong> {{ areaToDelete.codigo }}</p>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useAreaStore } from '../../../store/area.store'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
ReloadOutlined
} from '@ant-design/icons-vue'
// Store
const areaStore = useAreaStore()
// Estado
const modalVisible = ref(false)
const deleteModalVisible = ref(false)
const isEditing = ref(false)
const areaToDelete = ref(null)
const formRef = ref()
const searchText = ref('')
const statusFilter = ref(null)
const togglingId = ref(null)
// Formulario
const formState = reactive({
id: null,
nombre: '',
codigo: ''
})
// Reglas de validación del formulario
const formRules = {
nombre: [
{ required: true, message: 'Por favor ingresa el nombre del área', trigger: 'blur' },
{ min: 3, max: 100, message: 'El nombre debe tener entre 3 y 100 caracteres', trigger: 'blur' }
],
codigo: [
{ required: true, message: 'Por favor ingresa el código del área', trigger: 'blur' },
{ min: 2, max: 10, message: 'El código debe tener entre 2 y 10 caracteres', trigger: 'blur' }
]
}
// Paginación para la tabla
const pagination = computed(() => ({
current: areaStore.pagination.current_page,
pageSize: areaStore.pagination.per_page,
total: areaStore.pagination.total,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '30', '50'],
showTotal: (total, range) => `${range[0]}-${range[1]} de ${total} áreas`
}))
// Columnas de la tabla
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80
},
{
title: 'Nombre',
dataIndex: 'nombre',
key: 'nombre',
sorter: (a, b) => a.nombre.localeCompare(b.nombre)
},
{
title: 'Código',
dataIndex: 'codigo',
key: 'codigo',
width: 120
},
{
title: 'Estado',
dataIndex: 'activo',
key: 'activo',
width: 120
},
{
title: 'Creado',
dataIndex: 'created_at',
key: 'created_at',
width: 150
},
{
title: 'Acciones',
key: 'acciones',
width: 180,
align: 'center'
}
]
// Métodos
const showCreateModal = () => {
isEditing.value = false
resetForm()
modalVisible.value = true
areaStore.clearErrors()
}
const showEditModal = (area) => {
isEditing.value = true
resetForm()
// Llenar formulario con datos del área
formState.id = area.id
formState.nombre = area.nombre
formState.codigo = area.codigo
modalVisible.value = true
areaStore.clearErrors()
}
const confirmDelete = (area) => {
areaToDelete.value = area
deleteModalVisible.value = true
}
const handleDelete = async () => {
try {
await areaStore.deleteArea(areaToDelete.value.id)
message.success('Área eliminada correctamente')
deleteModalVisible.value = false
areaToDelete.value = null
} catch (error) {
message.error('Error al eliminar el área')
}
}
const handleModalOk = async () => {
try {
await formRef.value.validateFields()
await onFormSubmit()
} catch (error) {
console.log('Validación fallida:', error)
}
}
const handleModalCancel = () => {
modalVisible.value = false
resetForm()
areaStore.clearErrors()
}
const onFormSubmit = async () => {
const payload = {
nombre: formState.nombre,
codigo: formState.codigo
}
let success = false
if (isEditing.value) {
success = await areaStore.updateArea(formState.id, payload)
} else {
success = await areaStore.createArea(payload)
}
if (success) {
message.success(
isEditing.value
? 'Área actualizada correctamente'
: 'Área creada correctamente'
)
modalVisible.value = false
resetForm()
areaStore.clearErrors()
} else {
message.error('Error al guardar el área')
}
}
const resetForm = () => {
formState.id = null
formState.nombre = ''
formState.codigo = ''
if (formRef.value) {
formRef.value.resetFields()
}
}
const handleSearch = () => {
areaStore.setSearch(searchText.value)
areaStore.fetchAreas({ page: 1 })
}
const handleStatusFilterChange = (value) => {
areaStore.setActivo(value)
areaStore.fetchAreas({ page: 1 })
}
const handleClearFilters = () => {
searchText.value = ''
statusFilter.value = null
areaStore.clearFilters()
areaStore.fetchAreas({ page: 1 })
}
const handleTableChange = (paginationConfig, filters, sorter) => {
areaStore.fetchAreas({
page: paginationConfig.current,
per_page: paginationConfig.pageSize
})
}
const handleToggleStatus = async (area) => {
togglingId.value = area.id
try {
await areaStore.toggleArea(area.id)
message.success(`Área ${area.activo ? 'desactivada' : 'activada'} correctamente`)
} catch (error) {
message.error('Error al cambiar el estado del área')
} finally {
togglingId.value = null
}
}
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const getFieldStatus = (fieldName) => {
if (areaStore.errors && areaStore.errors[fieldName]) {
return 'error'
}
return ''
}
const getFieldHelp = (fieldName) => {
if (areaStore.errors && areaStore.errors[fieldName]) {
return areaStore.errors[fieldName][0]
}
return ''
}
// Lifecycle
onMounted(() => {
areaStore.fetchAreas()
})
</script>
<style scoped>
.areas-container {
padding: 0;
}
.areas-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 0 8px;
}
.header-title h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f1f1f;
}
.subtitle {
margin: 4px 0 0 0;
color: #666;
font-size: 14px;
}
.new-area-btn {
height: 40px;
font-weight: 500;
}
.filters-section {
display: flex;
align-items: center;
margin-bottom: 24px;
padding: 0 8px;
flex-wrap: wrap;
gap: 16px;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 16px;
}
.loading-state p {
color: #666;
margin: 0;
}
.empty-state {
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.areas-table-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
overflow: hidden;
}
.areas-table {
border-radius: 12px;
}
.areas-table :deep(.ant-table) {
border-radius: 12px;
}
.areas-table :deep(.ant-table-thead > tr > th) {
background: #fafafa;
font-weight: 600;
color: #1f1f1f;
border-bottom: 1px solid #f0f0f0;
}
.areas-table :deep(.ant-table-tbody > tr > td) {
border-bottom: 1px solid #f0f0f0;
}
.areas-table :deep(.ant-table-tbody > tr:hover > td) {
background: #fafafa;
}
.action-btn {
padding: 4px 8px;
height: auto;
}
.action-btn :deep(.anticon) {
font-size: 14px;
}
.area-modal :deep(.ant-modal-header) {
border-bottom: 1px solid #f0f0f0;
padding: 20px 24px;
}
.area-modal :deep(.ant-modal-body) {
padding: 24px;
}
.area-modal :deep(.ant-modal-footer) {
border-top: 1px solid #f0f0f0;
padding: 12px 24px;
}
.form-footer {
margin-top: 16px;
}
.error-alert {
margin-bottom: 8px;
}
.error-alert:last-child {
margin-bottom: 0;
}
.delete-confirm-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.area-info {
background: #fafafa;
padding: 12px;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.area-info p {
margin: 4px 0;
color: #666;
}
.area-info p strong {
color: #1f1f1f;
}
/* Responsive */
@media (max-width: 768px) {
.areas-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.new-area-btn {
align-self: flex-start;
}
.filters-section {
flex-direction: column;
align-items: stretch;
}
.filters-section .ant-input-search,
.filters-section .ant-select,
.filters-section .ant-btn {
width: 100%;
margin-left: 0;
margin-bottom: 8px;
}
.areas-table-container {
overflow-x: auto;
}
.areas-table {
min-width: 800px;
}
}
</style>

@ -0,0 +1,712 @@
<!-- components/AdminAcademia/Cursos/CursosList.vue -->
<template>
<div class="cursos-container">
<!-- Header con Título y Botón de Nuevo Curso -->
<div class="cursos-header">
<div class="header-title">
<h2>Cursos Académicos</h2>
<p class="subtitle">Gestiona los cursos académicos del sistema</p>
</div>
<a-button
type="primary"
@click="showCreateModal"
class="new-curso-btn"
>
<template #icon>
<PlusOutlined />
</template>
Nuevo Curso
</a-button>
</div>
<!-- Filtros -->
<div class="filters-section">
<a-input-search
v-model:value="searchText"
placeholder="Buscar por nombre o código..."
@search="handleSearch"
@press-enter="handleSearch"
style="width: 300px"
size="large"
>
<template #enterButton>
<SearchOutlined />
</template>
</a-input-search>
<a-select
v-model:value="statusFilter"
placeholder="Filtrar por estado"
style="width: 200px; margin-left: 16px"
size="large"
@change="handleStatusFilterChange"
>
<a-select-option :value="null">Todos los estados</a-select-option>
<a-select-option :value="true">Activo</a-select-option>
<a-select-option :value="false">Inactivo</a-select-option>
</a-select>
<a-button
@click="handleClearFilters"
style="margin-left: 16px"
size="large"
>
<template #icon>
<ReloadOutlined />
</template>
Limpiar filtros
</a-button>
</div>
<!-- Estado de Carga -->
<div v-if="cursoStore.loading && cursoStore.cursos.length === 0" class="loading-state">
<a-spin size="large" />
<p>Cargando cursos...</p>
</div>
<!-- Estado Vacío -->
<div v-else-if="cursoStore.cursos.length === 0" class="empty-state">
<a-empty description="No hay cursos registrados" />
</div>
<!-- Tabla de Cursos -->
<div v-else class="cursos-table-container">
<a-table
:data-source="cursoStore.cursos"
:columns="columns"
:loading="cursoStore.loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
class="cursos-table"
>
<template #bodyCell="{ column, record }">
<!-- Estado -->
<template v-if="column.key === 'activo'">
<a-switch
:checked="record.activo"
:loading="togglingId === record.id"
@change="handleToggleStatus(record)"
checked-children="Activo"
un-checked-children="Inactivo"
/>
</template>
<!-- Preguntas -->
<template v-if="column.key === 'preguntas'">
<div class="preguntas-info">
<span class="preguntas-count">{{ record.preguntas_count || 0 }}</span>
<a-button
type="link"
size="small"
@click="verPreguntasCurso(record)"
class="ver-preguntas-btn"
>
<EyeOutlined /> Ver preguntas
</a-button>
</div>
</template>
<!-- Fecha Creación -->
<template v-if="column.key === 'created_at'">
{{ formatDate(record.created_at) }}
</template>
<!-- Acciones -->
<template v-if="column.key === 'acciones'">
<a-space>
<a-button
type="link"
size="small"
@click="showEditModal(record)"
class="action-btn"
>
<EditOutlined /> Editar
</a-button>
<a-button
type="link"
size="small"
danger
@click="confirmDelete(record)"
class="action-btn"
>
<DeleteOutlined /> Eliminar
</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- Modal Crear/Editar Curso -->
<a-modal
v-model:open="modalVisible"
:title="isEditing ? 'Editar Curso' : 'Nuevo Curso'"
:confirm-loading="cursoStore.loading"
@ok="handleModalOk"
@cancel="handleModalCancel"
width="500px"
class="curso-modal"
>
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
layout="vertical"
@finish="onFormSubmit"
>
<a-form-item
label="Nombre del Curso"
name="nombre"
:validate-status="getFieldStatus('nombre')"
:help="getFieldHelp('nombre')"
>
<a-input
v-model:value="formState.nombre"
placeholder="Ej: Matemática Básica, Lenguaje Avanzado"
size="large"
/>
</a-form-item>
<a-form-item
label="Código"
name="codigo"
:validate-status="getFieldStatus('codigo')"
:help="getFieldHelp('codigo')"
>
<a-input
v-model:value="formState.codigo"
placeholder="Código único (ej: MAT101, LEN201)"
size="large"
/>
</a-form-item>
<a-form-item label="Estado" name="activo" v-if="isEditing">
<a-switch
v-model:checked="formState.activo"
checked-children="Activo"
un-checked-children="Inactivo"
/>
</a-form-item>
<div class="form-footer" v-if="cursoStore.errors">
<a-alert
v-for="(errorList, field) in cursoStore.errors"
:key="field"
type="error"
:message="errorList[0]"
show-icon
class="error-alert"
/>
</div>
</a-form>
</a-modal>
<!-- Modal de Confirmación para Eliminar -->
<a-modal
v-model:open="deleteModalVisible"
title="Confirmar Eliminación"
@ok="handleDelete"
@cancel="deleteModalVisible = false"
ok-text="Eliminar"
cancel-text="Cancelar"
ok-type="danger"
width="400px"
>
<div class="delete-confirm-content">
<a-alert
message="¿Estás seguro de eliminar este curso?"
description="Esta acción no se puede deshacer. Se eliminarán todas las preguntas asociadas al curso."
type="warning"
show-icon
/>
<div class="curso-info" v-if="cursoToDelete">
<p><strong>Curso:</strong> {{ cursoToDelete.nombre }}</p>
<p><strong>Código:</strong> {{ cursoToDelete.codigo }}</p>
<p><strong>Preguntas:</strong> {{ cursoToDelete.preguntas_count || 0 }}</p>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { useCursoStore } from '../../../store/curso.store'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
ReloadOutlined,
EyeOutlined
} from '@ant-design/icons-vue'
// Store y Router
const cursoStore = useCursoStore()
const router = useRouter()
// Estado
const modalVisible = ref(false)
const deleteModalVisible = ref(false)
const isEditing = ref(false)
const cursoToDelete = ref(null)
const formRef = ref()
const searchText = ref('')
const statusFilter = ref(null)
const togglingId = ref(null)
// Formulario
const formState = reactive({
id: null,
nombre: '',
codigo: '',
activo: true
})
// Reglas de validación del formulario
const formRules = {
nombre: [
{ required: true, message: 'Por favor ingresa el nombre del curso', trigger: 'blur' },
{ min: 3, max: 100, message: 'El nombre debe tener entre 3 y 100 caracteres', trigger: 'blur' }
],
codigo: [
{ required: true, message: 'Por favor ingresa el código del curso', trigger: 'blur' },
{ min: 2, max: 10, message: 'El código debe tener entre 2 y 10 caracteres', trigger: 'blur' }
]
}
// Paginación para la tabla
const pagination = computed(() => ({
current: cursoStore.pagination.current_page,
pageSize: cursoStore.pagination.per_page,
total: cursoStore.pagination.total,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '30', '50'],
showTotal: (total, range) => `${range[0]}-${range[1]} de ${total} cursos`
}))
// Columnas de la tabla
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 80
},
{
title: 'Nombre',
dataIndex: 'nombre',
key: 'nombre',
sorter: (a, b) => a.nombre.localeCompare(b.nombre)
},
{
title: 'Código',
dataIndex: 'codigo',
key: 'codigo',
width: 120
},
{
title: 'Estado',
dataIndex: 'activo',
key: 'activo',
width: 120
},
{
title: 'Preguntas',
key: 'preguntas',
width: 150
},
{
title: 'Creado',
dataIndex: 'created_at',
key: 'created_at',
width: 150
},
{
title: 'Acciones',
key: 'acciones',
width: 180,
align: 'center'
}
]
// Métodos
const showCreateModal = () => {
isEditing.value = false
resetForm()
modalVisible.value = true
cursoStore.errors = null
}
const showEditModal = (curso) => {
isEditing.value = true
resetForm()
// Llenar formulario con datos del curso
formState.id = curso.id
formState.nombre = curso.nombre
formState.codigo = curso.codigo
formState.activo = curso.activo
modalVisible.value = true
cursoStore.errors = null
}
const verPreguntasCurso = (curso) => {
router.push({ name: 'CursoPreguntas', params: { id: curso.id } })
}
const confirmDelete = (curso) => {
cursoToDelete.value = curso
deleteModalVisible.value = true
}
const handleDelete = async () => {
try {
const success = await cursoStore.deleteCurso(cursoToDelete.value.id)
if (success) {
message.success('Curso eliminado correctamente')
deleteModalVisible.value = false
cursoToDelete.value = null
} else {
message.error('Error al eliminar el curso')
}
} catch (error) {
message.error('Error al eliminar el curso')
}
}
const handleModalOk = async () => {
try {
await formRef.value.validateFields()
await onFormSubmit()
} catch (error) {
console.log('Validación fallida:', error)
}
}
const handleModalCancel = () => {
modalVisible.value = false
resetForm()
cursoStore.errors = null
}
const onFormSubmit = async () => {
const payload = {
nombre: formState.nombre,
codigo: formState.codigo
}
// Solo incluir activo si estamos editando
if (isEditing.value) {
payload.activo = formState.activo
}
let success = false
if (isEditing.value) {
success = await cursoStore.updateCurso(formState.id, payload)
} else {
success = await cursoStore.createCurso(payload)
}
if (success) {
message.success(
isEditing.value
? 'Curso actualizado correctamente'
: 'Curso creado correctamente'
)
modalVisible.value = false
resetForm()
cursoStore.errors = null
} else {
message.error('Error al guardar el curso')
}
}
const handleToggleStatus = async (curso) => {
togglingId.value = curso.id
try {
await cursoStore.toggleCurso(curso.id)
message.success(`Curso ${curso.activo ? 'desactivado' : 'activado'} correctamente`)
} catch (error) {
message.error('Error al cambiar el estado del curso')
} finally {
togglingId.value = null
}
}
const resetForm = () => {
formState.id = null
formState.nombre = ''
formState.codigo = ''
formState.activo = true
if (formRef.value) {
formRef.value.resetFields()
}
}
const handleSearch = () => {
cursoStore.setSearch(searchText.value)
cursoStore.fetchCursos({ page: 1 })
}
const handleStatusFilterChange = (value) => {
cursoStore.setActivo(value)
cursoStore.fetchCursos({ page: 1 })
}
const handleClearFilters = () => {
searchText.value = ''
statusFilter.value = null
cursoStore.clearFilters()
cursoStore.fetchCursos({ page: 1 })
}
const handleTableChange = (paginationConfig) => {
cursoStore.fetchCursos({
page: paginationConfig.current,
per_page: paginationConfig.pageSize
})
}
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('es-ES', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const getFieldStatus = (fieldName) => {
if (cursoStore.errors && cursoStore.errors[fieldName]) {
return 'error'
}
return ''
}
const getFieldHelp = (fieldName) => {
if (cursoStore.errors && cursoStore.errors[fieldName]) {
return cursoStore.errors[fieldName][0]
}
return ''
}
// Lifecycle
onMounted(() => {
cursoStore.fetchCursos()
})
</script>
<style scoped>
.cursos-container {
padding: 0;
}
.cursos-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 0 8px;
}
.header-title h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f1f1f;
}
.subtitle {
margin: 4px 0 0 0;
color: #666;
font-size: 14px;
}
.new-curso-btn {
height: 40px;
font-weight: 500;
}
.filters-section {
display: flex;
align-items: center;
margin-bottom: 24px;
padding: 0 8px;
flex-wrap: wrap;
gap: 16px;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 300px;
gap: 16px;
}
.loading-state p {
color: #666;
margin: 0;
}
.empty-state {
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.cursos-table-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
overflow: hidden;
}
.cursos-table {
border-radius: 12px;
}
.cursos-table :deep(.ant-table) {
border-radius: 12px;
}
.cursos-table :deep(.ant-table-thead > tr > th) {
background: #fafafa;
font-weight: 600;
color: #1f1f1f;
border-bottom: 1px solid #f0f0f0;
}
.cursos-table :deep(.ant-table-tbody > tr > td) {
border-bottom: 1px solid #f0f0f0;
}
.cursos-table :deep(.ant-table-tbody > tr:hover > td) {
background: #fafafa;
}
.preguntas-info {
display: flex;
align-items: center;
gap: 8px;
}
.preguntas-count {
font-weight: 600;
color: #1890ff;
min-width: 24px;
text-align: center;
}
.ver-preguntas-btn {
padding: 0;
height: auto;
}
.ver-preguntas-btn :deep(.anticon) {
font-size: 12px;
margin-right: 4px;
}
.action-btn {
padding: 4px 8px;
height: auto;
}
.action-btn :deep(.anticon) {
font-size: 14px;
}
.curso-modal :deep(.ant-modal-header) {
border-bottom: 1px solid #f0f0f0;
padding: 20px 24px;
}
.curso-modal :deep(.ant-modal-body) {
padding: 24px;
}
.curso-modal :deep(.ant-modal-footer) {
border-top: 1px solid #f0f0f0;
padding: 12px 24px;
}
.form-footer {
margin-top: 16px;
}
.error-alert {
margin-bottom: 8px;
}
.error-alert:last-child {
margin-bottom: 0;
}
.delete-confirm-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.curso-info {
background: #fafafa;
padding: 12px;
border-radius: 8px;
border: 1px solid #f0f0f0;
}
.curso-info p {
margin: 4px 0;
color: #666;
}
.curso-info p strong {
color: #1f1f1f;
}
/* Responsive */
@media (max-width: 768px) {
.cursos-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.new-curso-btn {
align-self: flex-start;
}
.filters-section {
flex-direction: column;
align-items: stretch;
}
.filters-section .ant-input-search,
.filters-section .ant-select,
.filters-section .ant-btn {
width: 100%;
margin-left: 0;
margin-bottom: 8px;
}
.cursos-table-container {
overflow-x: auto;
}
.cursos-table {
min-width: 800px;
}
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save