From b62acb8d625efaadea86734d83b0a7a296e5d1d7 Mon Sep 17 00:00:00 2001 From: elmer-20 <80175046+elmer-20@users.noreply.github.com> Date: Thu, 5 Feb 2026 15:32:38 -0500 Subject: [PATCH] v2 --- .../Administracion/AreaController.php | 192 ++ .../Administracion/CursoController.php | 188 ++ .../Administracion/ExamenesController.php | 488 +++++ .../Administracion/PreguntaController.php | 295 +++ .../Administracion/ProcesoController.php | 165 ++ back/app/Models/Area.php | 94 + back/app/Models/Curso.php | 39 + back/app/Models/Pregunta.php | 60 + back/app/Models/Proceso.php | 115 ++ back/routes/api.php | 63 +- .../components/SuperAdmin/AcademiasList.vue | 4 +- front/src/router/index.js | 46 +- front/src/store/area.store.js | 189 ++ front/src/store/curso.store.js | 118 ++ front/src/store/pregunta.store.js | 199 ++ front/src/store/proceso.store.js | 178 ++ front/src/views/administrador/Dashboard.vue | 1824 +++++++---------- .../administrador/Procesos/ProcesosList.vue | 489 +++++ .../views/administrador/areas/AreasList.vue | 641 ++++++ .../views/administrador/cursos/CursosList.vue | 712 +++++++ .../cursos/PreguntasCursoView.vue | 1711 ++++++++++++++++ .../src/views/administrador/layout/layout.vue | 1664 +++++++-------- 22 files changed, 7508 insertions(+), 1966 deletions(-) create mode 100644 back/app/Http/Controllers/Administracion/AreaController.php create mode 100644 back/app/Http/Controllers/Administracion/CursoController.php create mode 100644 back/app/Http/Controllers/Administracion/ExamenesController.php create mode 100644 back/app/Http/Controllers/Administracion/PreguntaController.php create mode 100644 back/app/Http/Controllers/Administracion/ProcesoController.php create mode 100644 back/app/Models/Area.php create mode 100644 back/app/Models/Curso.php create mode 100644 back/app/Models/Pregunta.php create mode 100644 back/app/Models/Proceso.php create mode 100644 front/src/store/area.store.js create mode 100644 front/src/store/curso.store.js create mode 100644 front/src/store/pregunta.store.js create mode 100644 front/src/store/proceso.store.js create mode 100644 front/src/views/administrador/Procesos/ProcesosList.vue create mode 100644 front/src/views/administrador/areas/AreasList.vue create mode 100644 front/src/views/administrador/cursos/CursosList.vue create mode 100644 front/src/views/administrador/cursos/PreguntasCursoView.vue diff --git a/back/app/Http/Controllers/Administracion/AreaController.php b/back/app/Http/Controllers/Administracion/AreaController.php new file mode 100644 index 0000000..d2e0447 --- /dev/null +++ b/back/app/Http/Controllers/Administracion/AreaController.php @@ -0,0 +1,192 @@ +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' + ]); + } +} diff --git a/back/app/Http/Controllers/Administracion/CursoController.php b/back/app/Http/Controllers/Administracion/CursoController.php new file mode 100644 index 0000000..8cf6db1 --- /dev/null +++ b/back/app/Http/Controllers/Administracion/CursoController.php @@ -0,0 +1,188 @@ +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' + ]); + } +} diff --git a/back/app/Http/Controllers/Administracion/ExamenesController.php b/back/app/Http/Controllers/Administracion/ExamenesController.php new file mode 100644 index 0000000..b01a8b0 --- /dev/null +++ b/back/app/Http/Controllers/Administracion/ExamenesController.php @@ -0,0 +1,488 @@ +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); + } + } + +} \ No newline at end of file diff --git a/back/app/Http/Controllers/Administracion/PreguntaController.php b/back/app/Http/Controllers/Administracion/PreguntaController.php new file mode 100644 index 0000000..fb93216 --- /dev/null +++ b/back/app/Http/Controllers/Administracion/PreguntaController.php @@ -0,0 +1,295 @@ +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' + ]); + } +} \ No newline at end of file diff --git a/back/app/Http/Controllers/Administracion/ProcesoController.php b/back/app/Http/Controllers/Administracion/ProcesoController.php new file mode 100644 index 0000000..70196b9 --- /dev/null +++ b/back/app/Http/Controllers/Administracion/ProcesoController.php @@ -0,0 +1,165 @@ +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 + ]); + } +} diff --git a/back/app/Models/Area.php b/back/app/Models/Area.php new file mode 100644 index 0000000..3ffcce2 --- /dev/null +++ b/back/app/Models/Area.php @@ -0,0 +1,94 @@ + '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(); + } +} diff --git a/back/app/Models/Curso.php b/back/app/Models/Curso.php new file mode 100644 index 0000000..729e07f --- /dev/null +++ b/back/app/Models/Curso.php @@ -0,0 +1,39 @@ + '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); + } +} diff --git a/back/app/Models/Pregunta.php b/back/app/Models/Pregunta.php new file mode 100644 index 0000000..e6ec570 --- /dev/null +++ b/back/app/Models/Pregunta.php @@ -0,0 +1,60 @@ + '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}%"); + }); + } +} diff --git a/back/app/Models/Proceso.php b/back/app/Models/Proceso.php new file mode 100644 index 0000000..e41c23e --- /dev/null +++ b/back/app/Models/Proceso.php @@ -0,0 +1,115 @@ + '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(); + } + }); + } +} diff --git a/back/routes/api.php b/back/routes/api.php index 619980a..2e67e12 100644 --- a/back/routes/api.php +++ b/back/routes/api.php @@ -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'] ); + +}); + -}); \ No newline at end of file diff --git a/front/src/components/SuperAdmin/AcademiasList.vue b/front/src/components/SuperAdmin/AcademiasList.vue index ebd70f9..87608f1 100644 --- a/front/src/components/SuperAdmin/AcademiasList.vue +++ b/front/src/components/SuperAdmin/AcademiasList.vue @@ -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') diff --git a/front/src/router/index.js b/front/src/router/index.js index 657f491..4f7b4a3 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -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', diff --git a/front/src/store/area.store.js b/front/src/store/area.store.js new file mode 100644 index 0000000..8e2efc1 --- /dev/null +++ b/front/src/store/area.store.js @@ -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 + }, + }, +}) diff --git a/front/src/store/curso.store.js b/front/src/store/curso.store.js new file mode 100644 index 0000000..ac246a5 --- /dev/null +++ b/front/src/store/curso.store.js @@ -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 + } + } + } +}) diff --git a/front/src/store/pregunta.store.js b/front/src/store/pregunta.store.js new file mode 100644 index 0000000..0f7acce --- /dev/null +++ b/front/src/store/pregunta.store.js @@ -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 + }, + }, +}) diff --git a/front/src/store/proceso.store.js b/front/src/store/proceso.store.js new file mode 100644 index 0000000..1b7e17c --- /dev/null +++ b/front/src/store/proceso.store.js @@ -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 + }, + }, +}) diff --git a/front/src/views/administrador/Dashboard.vue b/front/src/views/administrador/Dashboard.vue index 553bbf7..390b3e6 100644 --- a/front/src/views/administrador/Dashboard.vue +++ b/front/src/views/administrador/Dashboard.vue @@ -1,1266 +1,854 @@ - +