main
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in New Issue