main
elmer-20 2 months ago
parent 71182fd292
commit 87e72bc029

@ -15,7 +15,6 @@ class AreaController extends Controller
{
$query = Area::withCount(['cursos', 'procesos']);
// 🔍 Buscar por nombre o código
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
@ -24,7 +23,6 @@ class AreaController extends Controller
});
}
// 🔄 Filtrar por estado
if (!is_null($request->activo)) {
$query->where('activo', $request->activo);
}
@ -40,9 +38,6 @@ class AreaController extends Controller
}
/**
* Crear área
*/
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
@ -75,9 +70,6 @@ class AreaController extends Controller
], 201);
}
/**
* Mostrar área
*/
public function show($id)
{
$area = Area::with(['cursos', 'examenes'])->find($id);
@ -95,9 +87,6 @@ class AreaController extends Controller
]);
}
/**
* Actualizar área
*/
public function update(Request $request, $id)
{
$area = Area::find($id);
@ -127,7 +116,7 @@ class AreaController extends Controller
'nombre' => $request->nombre,
'codigo' => strtoupper($request->codigo),
'descripcion' => $request->descripcion,
'activo' => $request->activo ?? $area->activo, // mantener el valor actual si no viene
'activo' => $request->activo ?? $area->activo,
]);
return response()->json([
@ -137,9 +126,6 @@ class AreaController extends Controller
]);
}
/**
* Activar / Desactivar área (NO elimina)
*/
public function toggleEstado($id)
{
$area = Area::find($id);
@ -161,9 +147,6 @@ class AreaController extends Controller
]);
}
/**
* Eliminar área (solo si no tiene cursos ni exámenes)
*/
public function destroy($id)
{
$area = Area::with(['cursos', 'examenes'])->find($id);
@ -216,11 +199,8 @@ class AreaController extends Controller
if ($validator->fails()) {
return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
}
// Sincronizar cursos
$area->cursos()->sync($request->cursos);
// Recargar cursos
$area->load('cursos:id,nombre,codigo');
return response()->json([
@ -356,10 +336,8 @@ class AreaController extends Controller
], 422);
}
// 🔄 Sincronizar procesos
$area->procesos()->sync($request->procesos);
// 🔁 Recargar procesos vinculados
$area->load('procesos:id,nombre,tipo_proceso');
return response()->json([

@ -9,14 +9,12 @@ 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) {
@ -25,7 +23,6 @@ class CursoController extends Controller
});
}
// 🔄 Filtrar por estado
if ($request->filled('activo')) {
$query->where('activo', $request->activo);
}
@ -40,9 +37,7 @@ class CursoController extends Controller
]);
}
/**
* Crear curso
*/
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
@ -73,9 +68,7 @@ class CursoController extends Controller
], 201);
}
/**
* Mostrar curso
*/
public function show($id)
{
$curso = Curso::with('areas')->find($id);
@ -93,9 +86,7 @@ class CursoController extends Controller
]);
}
/**
* Actualizar curso
*/
public function update(Request $request, $id)
{
$curso = Curso::find($id);
@ -133,9 +124,6 @@ class CursoController extends Controller
]);
}
/**
* Activar / Desactivar curso
*/
public function toggleEstado($id)
{
$curso = Curso::find($id);
@ -157,9 +145,7 @@ class CursoController extends Controller
]);
}
/**
* Eliminar curso (solo si no tiene áreas ni preguntas asociadas)
*/
public function destroy($id)
{
$curso = Curso::with(['areas', 'preguntas'])->find($id);

@ -16,9 +16,6 @@ use Carbon\Carbon;
class ExamenesController extends Controller
{
/**
* Obtener exámenes de la academia
*/
public function getExamenes(Request $request)
{
try {
@ -46,7 +43,6 @@ class ExamenesController extends Controller
}])
->latest();
// Filtros
if ($request->has('search')) {
$search = $request->search;
$query->where('titulo', 'like', "%{$search}%");
@ -78,10 +74,7 @@ class ExamenesController extends Controller
], 500);
}
}
/**
* Crear nuevo examen
*/
public function crearExamen(Request $request)
{
try {
@ -140,7 +133,6 @@ class ExamenesController extends Controller
'intentos_permitidos' => $request->intentos_permitidos,
'puntaje_minimo' => $request->puntaje_minimo,
// 👇 OJO AQUÍ
'preguntas_aleatorias' => $request->preguntas_aleatorias ?? 0,
'mezclar_opciones' => $request->mezclar_opciones ?? true,
@ -188,9 +180,6 @@ class ExamenesController extends Controller
}
}
/**
* Obtener detalles de un examen
*/
public function getExamen($examenId)
{
try {
@ -223,7 +212,6 @@ class ExamenesController extends Controller
], 404);
}
// Estadísticas del examen
$estadisticas = DB::table('intentos_examen')
->where('examen_id', $examenId)
->where('estado', 'finalizado')
@ -252,9 +240,6 @@ class ExamenesController extends Controller
}
}
/**
* Actualizar examen
*/
public function actualizarExamen(Request $request, $examenId)
{
try {
@ -338,9 +323,6 @@ class ExamenesController extends Controller
}
}
/**
* Eliminar examen
*/
public function eliminarExamen($examenId)
{
try {
@ -371,7 +353,6 @@ class ExamenesController extends Controller
], 404);
}
// Verificar si hay intentos realizados
$tieneIntentos = $examen->intentos()->exists();
if ($tieneIntentos) {
@ -406,9 +387,6 @@ class ExamenesController extends Controller
}
}
/**
* Obtener resultados de un examen
*/
public function getResultadosExamen($examenId)
{
try {
@ -455,8 +433,7 @@ class ExamenesController extends Controller
)
->orderBy('intentos_examen.porcentaje', 'desc')
->get();
// Estadísticas generales
$estadisticas = [
'total_estudiantes' => $resultados->groupBy('estudiante_id')->count(),
'promedio' => $resultados->avg('porcentaje'),

@ -87,7 +87,6 @@ class PreguntaController extends Controller
return response()->json(['success' => false, 'message' => 'No autorizado'], 403);
}
// Validación (igual que antes)
$validator = Validator::make($request->all(), [
'curso_id' => 'required|exists:cursos,id',
'enunciado' => 'required|string',
@ -107,21 +106,21 @@ class PreguntaController extends Controller
return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
}
// Opciones
$opciones = is_string($request->opciones) ? json_decode($request->opciones, true) : $request->opciones;
$opcionesValidas = array_map('trim', $opciones);
// Validar respuesta correcta
if (!in_array($request->respuesta_correcta, $opcionesValidas)) {
return response()->json(['success' => false, 'errors' => ['respuesta_correcta' => ['La respuesta correcta debe estar entre las opciones']]] ,422);
}
// Procesar imágenes del enunciado y devolver URLs completas
$imagenesUrls = [];
if ($request->hasFile('imagenes')) {
foreach ($request->file('imagenes') as $imagen) {
$path = $imagen->store('preguntas/enunciados', 'public');
$imagenesUrls[] = url(Storage::url($path)); // URL completa
$imagenesUrls[] = url(Storage::url($path));
}
}
@ -130,11 +129,11 @@ class PreguntaController extends Controller
if ($request->hasFile('imagenes_explicacion')) {
foreach ($request->file('imagenes_explicacion') as $imagen) {
$path = $imagen->store('preguntas/explicaciones', 'public');
$imagenesExplicacionUrls[] = url(Storage::url($path)); // URL completa
$imagenesExplicacionUrls[] = url(Storage::url($path));
}
}
// Crear pregunta
$pregunta = Pregunta::create([
'curso_id' => $request->curso_id,
'enunciado' => $request->enunciado,
@ -190,7 +189,6 @@ class PreguntaController extends Controller
return response()->json(['success' => false, 'errors' => $validator->errors()], 422);
}
// Decodificar opciones
$opciones = is_string($request->opciones) ? json_decode($request->opciones, true) : $request->opciones;
$opcionesValidas = array_map('trim', $opciones);
@ -201,7 +199,7 @@ class PreguntaController extends Controller
], 422);
}
// --- Imágenes del enunciado ---
$imagenesActuales = $request->input('imagenes_existentes', $pregunta->imagenes ?? []);
if (is_string($imagenesActuales)) {
$imagenesActuales = json_decode($imagenesActuales, true) ?? [];
@ -214,7 +212,6 @@ class PreguntaController extends Controller
}
}
// --- Imágenes de la explicación ---
$imagenesExplicacionActuales = $request->input('imagenes_explicacion_existentes', $pregunta->imagenes_explicacion ?? []);
if (is_string($imagenesExplicacionActuales)) {
$imagenesExplicacionActuales = json_decode($imagenesExplicacionActuales, true) ?? [];
@ -227,7 +224,6 @@ class PreguntaController extends Controller
}
}
// Actualizar pregunta
$pregunta->update([
'curso_id' => $request->curso_id,
'enunciado' => $request->enunciado,
@ -269,7 +265,6 @@ class PreguntaController extends Controller
], 404);
}
// Eliminar imágenes del storage si existen
if ($pregunta->imagenes) {
foreach ($pregunta->imagenes as $imagen) {
Storage::disk('public')->delete($imagen);

@ -11,7 +11,7 @@ use Illuminate\Validation\Rule;
class ProcesoAdmisionController extends Controller
{
// GET /api/admin/procesos-admision?include_detalles=1&q=...&estado=...&publicado=1&page=1&per_page=15
public function index(Request $request)
{
$q = ProcesoAdmision::query();
@ -43,7 +43,6 @@ class ProcesoAdmisionController extends Controller
return response()->json($q->paginate($perPage));
}
// GET /api/admin/procesos-admision/{id}?include_detalles=1
public function show(Request $request, $id)
{
$q = ProcesoAdmision::query();
@ -55,7 +54,6 @@ class ProcesoAdmisionController extends Controller
return response()->json($q->findOrFail($id));
}
// POST /api/admin/procesos-admision (multipart/form-data)
public function store(Request $request)
{
$data = $request->validate([
@ -88,20 +86,17 @@ class ProcesoAdmisionController extends Controller
'estado' => ['sometimes', Rule::in(['nuevo','publicado','en_proceso','finalizado','cancelado'])],
// Archivos (uploads)
'imagen' => ['nullable','image','mimes:jpg,jpeg,png,webp','max:10240'],
'banner' => ['nullable','image','mimes:jpg,jpeg,png,webp','max:10240'],
'brochure' => ['nullable','file','mimes:pdf','max:10240'],
]);
// slug auto si no viene
if (empty($data['slug'])) {
$data['slug'] = Str::slug($data['titulo']);
}
$data['publicado'] = $data['publicado'] ?? false;
// Guardar archivos
if ($request->hasFile('imagen')) {
$data['imagen_path'] = $request->file('imagen')->store('admisiones/procesos', 'public');
}
@ -117,7 +112,6 @@ class ProcesoAdmisionController extends Controller
return response()->json($proceso, 201);
}
// PATCH /api/admin/procesos-admision/{id} (multipart/form-data)
public function update(Request $request, $id)
{
$proceso = ProcesoAdmision::findOrFail($id);
@ -152,18 +146,16 @@ class ProcesoAdmisionController extends Controller
'estado' => ['sometimes', Rule::in(['nuevo','publicado','en_proceso','finalizado','cancelado'])],
// Archivos
'imagen' => ['sometimes','nullable','image','mimes:jpg,jpeg,png,webp','max:10240'],
'banner' => ['sometimes','nullable','image','mimes:jpg,jpeg,png,webp','max:10240'],
'brochure' => ['sometimes','nullable','file','mimes:pdf','max:10240'],
]);
// slug auto si lo mandan vacío
if (array_key_exists('slug', $data) && empty($data['slug'])) {
$data['slug'] = Str::slug($data['titulo'] ?? $proceso->titulo);
}
// Reemplazo de archivos (borra anterior)
if ($request->hasFile('imagen')) {
if ($proceso->imagen_path) Storage::disk('public')->delete($proceso->imagen_path);
$data['imagen_path'] = $request->file('imagen')->store('admisiones/procesos', 'public');
@ -182,12 +174,11 @@ class ProcesoAdmisionController extends Controller
return response()->json($proceso->fresh());
}
// DELETE /api/admin/procesos-admision/{id}
public function destroy($id)
{
$proceso = ProcesoAdmision::findOrFail($id);
// Borrar archivos asociados
if ($proceso->imagen_path) Storage::disk('public')->delete($proceso->imagen_path);
if ($proceso->banner_path) Storage::disk('public')->delete($proceso->banner_path);
if ($proceso->brochure_path) Storage::disk('public')->delete($proceso->brochure_path);

@ -11,7 +11,7 @@ use Illuminate\Validation\Rule;
class ProcesoAdmisionDetalleController extends Controller
{
// GET /api/admin/procesos-admision/{procesoId}/detalles?tipo=requisitos
public function index(Request $request, $procesoId)
{
ProcesoAdmision::findOrFail($procesoId);
@ -25,7 +25,7 @@ class ProcesoAdmisionDetalleController extends Controller
return response()->json($q->orderByDesc('id')->get());
}
// POST /api/admin/procesos-admision/{procesoId}/detalles (multipart/form-data)
public function store(Request $request, $procesoId)
{
ProcesoAdmision::findOrFail($procesoId);
@ -58,13 +58,13 @@ class ProcesoAdmisionDetalleController extends Controller
return response()->json($detalle, 201);
}
// GET /api/admin/detalles-admision/{id}
public function show($id)
{
return response()->json(ProcesoAdmisionDetalle::findOrFail($id));
}
// PATCH /api/admin/detalles-admision/{id} (multipart/form-data)
public function update(Request $request, $id)
{
$detalle = ProcesoAdmisionDetalle::findOrFail($id);
@ -98,7 +98,6 @@ class ProcesoAdmisionDetalleController extends Controller
return response()->json($detalle->fresh());
}
// DELETE /api/admin/detalles-admision/{id}
public function destroy($id)
{
$detalle = ProcesoAdmisionDetalle::findOrFail($id);

@ -12,14 +12,11 @@ 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 . '%');
}
@ -36,7 +33,6 @@ class ProcesoController extends Controller
$query->where('tipo_proceso', $request->tipo_proceso);
}
// 📄 Paginación
$procesos = $query
->orderBy('created_at', 'desc')
->paginate($request->get('per_page', 10));
@ -44,9 +40,6 @@ class ProcesoController extends Controller
return response()->json($procesos);
}
/* =============================
| CREAR (STORE)
============================= */
public function store(Request $request)
{
$data = $request->validate([
@ -82,9 +75,6 @@ class ProcesoController extends Controller
], 201);
}
/* =============================
| VER (SHOW)
============================= */
public function show($id)
{
$proceso = Proceso::findOrFail($id);
@ -92,9 +82,7 @@ class ProcesoController extends Controller
return response()->json($proceso);
}
/* =============================
| ACTUALIZAR (UPDATE)
============================= */
public function update(Request $request, $id)
{
$proceso = Proceso::findOrFail($id);
@ -122,7 +110,6 @@ class ProcesoController extends Controller
'tiempo_por_pregunta' => 'nullable|integer|min:1',
]);
// 🔄 Regenerar slug si cambia nombre
if ($data['nombre'] !== $proceso->nombre) {
$data['slug'] = Str::slug($data['nombre']) . '-' . uniqid();
}
@ -135,9 +122,7 @@ class ProcesoController extends Controller
]);
}
/* =============================
| ELIMINAR (DESTROY)
============================= */
public function destroy($id)
{
$proceso = Proceso::findOrFail($id);
@ -148,9 +133,7 @@ class ProcesoController extends Controller
]);
}
/* =============================
| TOGGLE ACTIVO
============================= */
public function toggleActivo($id)
{
$proceso = Proceso::findOrFail($id);

@ -11,18 +11,12 @@ use Illuminate\Support\Facades\DB;
class ReglaAreaProcesoController extends Controller
{
/**
* Mostrar cursos de un area_proceso con reglas existentes
*/
public function areasProcesos()
ion areasProcesos()
{
$areasProcesos = DB::table('area_proceso as ap')
->leftJoin('reglas_area_proceso as r', 'ap.id', '=', 'r.area_proceso_id')
->leftJoin('area_curso as ac', 'ap.area_id', '=', 'ac.area_id') // pivot area_curso
->leftJoin('cursos as c', 'ac.curso_id', '=', 'c.id') // unimos los cursos reales
->leftJoin('area_curso as ac', 'ap.area_id', '=', 'ac.area_id')
->leftJoin('cursos as c', 'ac.curso_id', '=', 'c.id')
->join('areas as a', 'ap.area_id', '=', 'a.id')
->join('procesos as p', 'ap.proceso_id', '=', 'p.id')
->select(
@ -48,7 +42,7 @@ class ReglaAreaProcesoController extends Controller
public function index($areaProcesoId)
{
// Obtener el area_proceso y su proceso
$areaProceso = DB::table('area_proceso as ap')
->join('areas as a', 'a.id', '=', 'ap.area_id')
->join('procesos as p', 'p.id', '=', 'ap.proceso_id')
@ -61,19 +55,16 @@ public function index($areaProcesoId)
return response()->json(['error' => 'AreaProceso no encontrado'], 404);
}
// Traer todos los cursos del área (pivot area_curso)
$cursos = DB::table('area_curso as ac')
->join('cursos as c', 'c.id', '=', 'ac.curso_id')
->where('ac.area_id', $areaProceso->area_id)
->select('c.id as curso_id', 'c.nombre as nombre')
->get();
// Traer reglas existentes para este area_proceso
$reglasExistentes = DB::table('reglas_area_proceso')
->where('area_proceso_id', $areaProcesoId)
->get();
// Mapear cursos con reglas si existen
$reglas = $cursos->map(function ($curso) use ($reglasExistentes) {
$regla = $reglasExistentes->firstWhere('curso_id', $curso->curso_id);
return [
@ -109,7 +100,6 @@ public function store(Request $request, $areaProcesoId)
'ponderacion' => 'nullable|numeric|min:0|max:100',
]);
// 🔹 Cantidad total de preguntas del proceso (vía pivot)
$areaProceso = DB::table('area_proceso as ap')
->join('procesos as p', 'ap.proceso_id', '=', 'p.id')
->where('ap.id', $areaProcesoId)
@ -124,7 +114,6 @@ public function store(Request $request, $areaProcesoId)
$totalPreguntasProceso = $areaProceso->cantidad_pregunta;
// 🔹 Total ya asignado (excluyendo este curso si ya existe)
$totalAsignado = DB::table('reglas_area_proceso')
->where('area_proceso_id', $areaProcesoId)
->where('curso_id', '!=', $request->curso_id)
@ -139,7 +128,6 @@ public function store(Request $request, $areaProcesoId)
], 422);
}
// 🔹 Insertar o actualizar UNA regla
DB::table('reglas_area_proceso')->updateOrInsert(
[
'area_proceso_id' => $areaProcesoId,
@ -177,7 +165,6 @@ public function update(Request $request, $reglaId)
'ponderacion' => 'nullable|numeric|min:0|max:100',
]);
// 🔹 Obtener la regla actual
$regla = DB::table('reglas_area_proceso')
->where('id', $reglaId)
->first();
@ -188,7 +175,6 @@ public function update(Request $request, $reglaId)
], 404);
}
// 🔹 Obtener cantidad total de preguntas del proceso (vía pivot)
$areaProceso = DB::table('area_proceso as ap')
->join('procesos as p', 'ap.proceso_id', '=', 'p.id')
->where('ap.id', $regla->area_proceso_id)
@ -203,7 +189,6 @@ public function update(Request $request, $reglaId)
$totalPreguntasProceso = $areaProceso->cantidad_pregunta;
// 🔹 Total asignado EXCLUYENDO esta regla
$totalAsignado = DB::table('reglas_area_proceso')
->where('area_proceso_id', $regla->area_proceso_id)
->where('id', '!=', $reglaId)
@ -218,7 +203,6 @@ public function update(Request $request, $reglaId)
], 422);
}
// 🔹 Actualizar la regla
DB::table('reglas_area_proceso')
->where('id', $reglaId)
->update([
@ -241,9 +225,7 @@ public function update(Request $request, $reglaId)
]);
}
/**
* Eliminar una regla
*/
public function destroy($reglaId)
{
$regla = ReglaAreaProceso::findOrFail($reglaId);
@ -272,7 +254,6 @@ public function storeMultiple(Request $request, $areaProcesoId)
'reglas.*.ponderacion' => 'nullable|numeric|min:0|max:100',
]);
// Obtener la cantidad total de preguntas del proceso a través del pivot
$areaProceso = DB::table('area_proceso as ap')
->join('procesos as p', 'ap.proceso_id', '=', 'p.id')
->where('ap.id', $areaProcesoId)
@ -281,7 +262,6 @@ public function storeMultiple(Request $request, $areaProcesoId)
$totalPreguntasProceso = $areaProceso->cantidad_pregunta ?? 0;
// Validar total de preguntas asignadas
$totalNuevo = collect($request->reglas)->sum('cantidad_preguntas');
if ($totalNuevo > $totalPreguntasProceso) {
return response()->json([
@ -289,10 +269,8 @@ public function storeMultiple(Request $request, $areaProcesoId)
], 422);
}
// Eliminar reglas existentes directamente en la tabla pivot
DB::table('reglas_area_proceso')->where('area_proceso_id', $areaProcesoId)->delete();
// Insertar las nuevas reglas
$insertData = collect($request->reglas)->map(function ($r) use ($areaProcesoId) {
return [
'area_proceso_id' => $areaProcesoId,

@ -43,15 +43,14 @@ class AuthController extends Controller
'name' => strip_tags(trim($request->name)),
'email' => strtolower(trim($request->email)),
'password' => Hash::make($request->password),
'email_verified_at' => null, // Para implementar verificación de email después
'email_verified_at' => null,
]);
$user->assignRole('administrador');
// Registrar actividad
Log::info('Usuario registrado', ['user_id' => $user->id, 'email' => $user->email]);
// Crear token de acceso
$token = $user->createToken('api_token', ['*'], now()->addHours(12))->plainTextToken;
return response()->json([
@ -66,7 +65,7 @@ class AuthController extends Controller
],
'token' => $token,
'token_type' => 'Bearer',
'expires_in' => 12 * 60 * 60 // 12 horas en segundos
'expires_in' => 12 * 60 * 60
], 201);
} catch (\Exception $e) {
@ -82,9 +81,6 @@ class AuthController extends Controller
}
}
/**
* Login de usuario
*/
public function login(Request $request)
{
try {
@ -105,9 +101,8 @@ class AuthController extends Controller
'password' => $request->password
];
// Intentar autenticación
if (!Auth::attempt($credentials)) {
// Registrar intento fallido
Log::warning('Intento de login fallido', ['email' => $request->email]);
return response()->json([
@ -123,13 +118,10 @@ class AuthController extends Controller
// return response()->json(['error' => 'Cuenta desactivada'], 403);
// }
// Revocar tokens anteriores (opcional, para seguridad)
$user->tokens()->delete();
// Crear nuevo token con expiración
$token = $user->createToken('api_token', ['*'], now()->addHours(12))->plainTextToken;
// Registrar login exitoso
Log::info('Login exitoso', ['user_id' => $user->id]);
return response()->json([
@ -160,17 +152,13 @@ class AuthController extends Controller
}
}
/**
* Logout de usuario
*/
public function logout(Request $request)
{
try {
if ($request->user()) {
// Registrar logout
Log::info('Logout exitoso', ['user_id' => $request->user()->id]);
// Revocar todos los tokens del usuario
$request->user()->tokens()->delete();
}
@ -189,9 +177,7 @@ class AuthController extends Controller
}
}
/**
* Obtener usuario actual
*/
public function me(Request $request)
{
try {
@ -225,9 +211,7 @@ class AuthController extends Controller
}
}
/**
* Refrescar token (opcional para implementar después)
*/
public function refresh(Request $request)
{
$user = $request->user();

@ -71,7 +71,7 @@ public function areas(Request $request)
return [
'area_id' => $area->id,
'nombre' => $area->nombre,
'area_proceso_id' => $pivot->id, // 🔥 CLAVE
'area_proceso_id' => $pivot->id,
];
});
@ -271,7 +271,7 @@ public function miExamenActual(Request $request)
{
$postulante = $request->user();
// Obtenemos el examen más reciente junto con área y proceso usando joins
$examen = Examen::join('area_proceso', 'area_proceso.id', '=', 'examenes.area_proceso_id')
->join('areas', 'areas.id', '=', 'area_proceso.area_id')
->join('procesos', 'procesos.id', '=', 'area_proceso.proceso_id')
@ -280,14 +280,14 @@ public function miExamenActual(Request $request)
'examenes.id',
'examenes.pagado',
'examenes.tipo_pago',
'examenes.intentos', // intentos del examen
'examenes.intentos',
'areas.id as area_id',
'areas.nombre as area_nombre',
'procesos.id as proceso_id',
'procesos.nombre as proceso_nombre',
'procesos.intentos_maximos as proceso_intentos_maximos' // intentos máximos del proceso
'procesos.intentos_maximos as proceso_intentos_maximos'
)
->latest('examenes.created_at') // solo el más reciente
->latest('examenes.created_at')
->first();
if (!$examen) {
@ -321,14 +321,11 @@ public function miExamenActual(Request $request)
/**
* 2. GENERAR PREGUNTAS PARA EXAMEN (si no las tiene)
*/
public function generarPreguntas($examenId)
{
$examen = Examen::findOrFail($examenId);
// Verificar que el examen pertenece al usuario autenticado
$postulante = request()->user();
if ($examen->postulante_id !== $postulante->id) {
return response()->json([
@ -337,7 +334,6 @@ public function generarPreguntas($examenId)
], 403);
}
// Si YA tiene preguntas, no generar nuevas, solo confirmar éxito
if ($examen->preguntasAsignadas()->exists()) {
return response()->json([
'success' => true,
@ -347,7 +343,6 @@ public function generarPreguntas($examenId)
]);
}
// Si NO tiene preguntas, generar usando el servicio
$resultado = $this->examenService->generarPreguntasExamen($examen);
if (!$resultado['success']) {
@ -363,12 +358,6 @@ public function generarPreguntas($examenId)
}
/**
* 4. INICIAR EXAMEN (marcar hora inicio)
*/
/**
* 4. INICIAR EXAMEN (marcar hora inicio e incrementar intentos)
*/
public function iniciarExamen(Request $request)
{
$request->validate([
@ -377,7 +366,6 @@ public function iniciarExamen(Request $request)
$examen = Examen::findOrFail($request->examen_id);
// Verificar que el examen pertenece al usuario autenticado
$postulante = $request->user();
if ($examen->postulante_id !== $postulante->id) {
return response()->json([
@ -386,7 +374,6 @@ public function iniciarExamen(Request $request)
], 403);
}
// Obtener datos del área-proceso
$areaProceso = \DB::table('area_proceso')
->join('procesos', 'area_proceso.proceso_id', '=', 'procesos.id')
->join('areas', 'area_proceso.area_id', '=', 'areas.id')
@ -400,7 +387,6 @@ public function iniciarExamen(Request $request)
)
->first();
// Verificar que tenga preguntas
if (!$examen->preguntasAsignadas()->exists()) {
return response()->json([
'success' => false,
@ -408,7 +394,6 @@ public function iniciarExamen(Request $request)
], 400);
}
// Verificar límite de intentos
if ($areaProceso && $examen->intentos >= $areaProceso->proceso_intentos_maximos) {
return response()->json([
'success' => false,
@ -416,15 +401,13 @@ public function iniciarExamen(Request $request)
], 403);
}
// 🔥 Incrementar intento y marcar inicio
$examen->increment('intentos');
$examen->update([
'hora_inicio' => now(), // Hora normal del servidor
'hora_inicio' => now(),
'estado' => 'en_progreso'
]);
// Obtener preguntas completas
$preguntas = $this->examenService->obtenerPreguntasExamen($examen);
return response()->json([
@ -454,7 +437,6 @@ public function iniciarExamen(Request $request)
$preguntaAsignada = PreguntaAsignada::with(['examen', 'pregunta'])
->findOrFail($preguntaAsignadaId);
// Verificar que pertenece al usuario
$postulante = $request->user();
if ($preguntaAsignada->examen->postulante_id !== $postulante->id) {
return response()->json([
@ -463,7 +445,6 @@ public function iniciarExamen(Request $request)
], 403);
}
// Guardar respuesta
$resultado = $this->examenService->guardarRespuesta(
$preguntaAsignada,
$request->respuesta
@ -472,14 +453,11 @@ public function iniciarExamen(Request $request)
return response()->json($resultado);
}
/**
* 6. FINALIZAR EXAMEN
*/
public function finalizarExamen($examenId)
{
$examen = Examen::findOrFail($examenId);
// Verificar que el examen pertenece al usuario autenticado
$postulante = request()->user();
if ($examen->postulante_id !== $postulante->id) {
return response()->json([

@ -252,7 +252,7 @@ public function misProcesos(Request $request)
$procesos = ResultadoAdmision::select(
'procesos_admision.id',
'procesos_admision.nombre',
'procesos_admision.titulo',
'resultados_admision.puntaje',
'resultados_admision.apto'
)

@ -23,8 +23,6 @@ class Area extends Model
'updated_at' => 'datetime',
];
/* ================= RELACIONES ================= */
public function examenes()
{
@ -36,7 +34,6 @@ class Area extends Model
)->withTimestamps();
}
/* ================= SCOPES ================= */
public function scopeActivas($query)
{

@ -18,13 +18,7 @@ class AreaAdmision extends Model
'estado' => 'boolean'
];
/*
|--------------------------------------------------------------------------
| RELACIONES
|--------------------------------------------------------------------------
*/
// Un área tiene muchos resultados
public function resultados()
{
return $this->hasMany(ResultadoAdmision::class, 'idearea');

@ -32,7 +32,7 @@ class Curso extends Model
}
// Curso → Preguntas
public function preguntas()
{
return $this->hasMany(Pregunta::class);

@ -49,10 +49,10 @@ class Examen extends Model
return $this->hasOneThrough(
Area::class,
AreaProceso::class,
'id', // Foreign key on AreaProceso table...
'id', // Foreign key on Area table...
'area_proceso_id', // Local key on Examen table...
'area_id' // Local key on AreaProceso table...
'id',
'id',
'area_proceso_id',
'area_id'
);
}

@ -13,9 +13,6 @@ class Postulante extends Authenticatable
protected $table = 'postulantes';
/**
* Campos asignables
*/
protected $fillable = [
'name',
'email',
@ -25,25 +22,19 @@ class Postulante extends Authenticatable
'last_activity'
];
/**
* Campos ocultos al serializar
*/
protected $hidden = [
'password',
'device_id',
'tokens'
];
/**
* Casting
*/
protected $casts = [
'last_activity' => 'datetime',
];
/**
* Mutator para encriptar la contraseña automáticamente
*/
public function setPasswordAttribute($value)
{
$this->attributes['password'] = bcrypt($value);

@ -101,9 +101,6 @@ class Proceso extends Model
return true;
}
/* =============================
| EVENTS
============================= */
protected static function booted()
{
@ -118,10 +115,10 @@ public function areas()
{
return $this->belongsToMany(
Area::class,
'area_proceso' // nombre de la tabla pivot
'area_proceso'
)
->withPivot('id') // columnas extra de la tabla pivot que quieres acceder
->withTimestamps(); // para manejar created_at y updated_at automáticamente
->withPivot('id')
->withTimestamps();
}

@ -60,7 +60,7 @@ class ProcesoAdmision extends Model
return $this->brochure_path ? Storage::disk('public')->url($this->brochure_path) : null;
}
// Un proceso tiene muchos resultados
public function resultados()
{
return $this->hasMany(ResultadoAdmision::class, 'idproceso');

@ -37,11 +37,6 @@ class ResultadoAdmision extends Model
'respuestas' => 'array'
];
/*
|--------------------------------------------------------------------------
| RELACIONES
|--------------------------------------------------------------------------
*/
public function proceso()
{

@ -10,131 +10,109 @@ class ResultadoAdmisionCarga extends Model
protected $primaryKey = 'id';
public $timestamps = false; // Solo tienes created_at
public $timestamps = false;
protected $guarded = [];
// Uso guarded vacío porque tienes MUCHOS campos.
// Es más práctico que escribir todos en fillable.
protected $casts = [
'puntaje_total' => 'decimal:2',
'puesto' => 'integer',
// ARITMETICA
'correctas_aritmetica' => 'integer',
'blancas_aritmetica' => 'integer',
'puntaje_aritmetica' => 'decimal:2',
'porcentaje_aritmetica' => 'decimal:2',
// ALGEBRA
'correctas_algebra' => 'integer',
'blancas_algebra' => 'integer',
'puntaje_algebra' => 'decimal:2',
'porcentaje_algebra' => 'decimal:2',
// GEOMETRIA
'correctas_geometria' => 'integer',
'blancas_geometria' => 'integer',
'puntaje_geometria' => 'decimal:2',
'porcentaje_geometria' => 'decimal:2',
// TRIGONOMETRIA
'correctas_trigonometria' => 'integer',
'blancas_trigonometria' => 'integer',
'puntaje_trigonometria' => 'decimal:2',
'porcentaje_trigonometria' => 'decimal:2',
// FISICA
'correctas_fisica' => 'integer',
'blancas_fisica' => 'integer',
'puntaje_fisica' => 'decimal:2',
'porcentaje_fisica' => 'decimal:2',
// QUIMICA
'correctas_quimica' => 'integer',
'blancas_quimica' => 'integer',
'puntaje_quimica' => 'decimal:2',
'porcentaje_quimica' => 'decimal:2',
// BIOLOGIA
'correctas_biologia_anatomia' => 'integer',
'blancas_biologia_anatomia' => 'integer',
'puntaje_biologia_anatomia' => 'decimal:2',
'porcentaje_biologia_anatomia' => 'decimal:2',
// PSICOLOGIA
'correctas_psicologia_filosofia' => 'integer',
'blancas_psicologia_filosofia' => 'integer',
'puntaje_psicologia_filosofia' => 'decimal:2',
'porcentaje_psicologia_filosofia' => 'decimal:2',
// GEOGRAFIA
'correctas_geografia' => 'integer',
'blancas_geografia' => 'integer',
'puntaje_geografia' => 'decimal:2',
'porcentaje_geografia' => 'decimal:2',
// HISTORIA
'correctas_historia' => 'integer',
'blancas_historia' => 'integer',
'puntaje_historia' => 'decimal:2',
'porcentaje_historia' => 'decimal:2',
// EDUCACION CIVICA
'correctas_educacion_civica' => 'integer',
'blancas_educacion_civica' => 'integer',
'puntaje_educacion_civica' => 'decimal:2',
'porcentaje_educacion_civica' => 'decimal:2',
// ECONOMIA
'correctas_economia' => 'integer',
'blancas_economia' => 'integer',
'puntaje_economia' => 'decimal:2',
'porcentaje_economia' => 'decimal:2',
// COMUNICACION
'correctas_comunicacion' => 'integer',
'blancas_comunicacion' => 'integer',
'puntaje_comunicacion' => 'decimal:2',
'porcentaje_comunicacion' => 'decimal:2',
// LITERATURA
'correctas_literatura' => 'integer',
'blancas_literatura' => 'integer',
'puntaje_literatura' => 'decimal:2',
'porcentaje_literatura' => 'decimal:2',
// RAZONAMIENTO MATEMATICO
'correctas_razonamiento_matematico' => 'integer',
'blancas_razonamiento_matematico' => 'integer',
'puntaje_razonamiento_matematico' => 'decimal:2',
'porcentaje_razonamiento_matematico' => 'decimal:2',
// RAZONAMIENTO VERBAL
'correctas_razonamiento_verbal' => 'integer',
'blancas_razonamiento_verbal' => 'integer',
'puntaje_razonamiento_verbal' => 'decimal:2',
'porcentaje_razonamiento_verbal' => 'decimal:2',
// INGLES
'correctas_ingles' => 'integer',
'blancas_ingles' => 'integer',
'puntaje_ingles' => 'decimal:2',
'porcentaje_ingles' => 'decimal:2',
// QUECHUA / AIMARA
'correctas_quechua_aimara' => 'integer',
'blancas_quechua_aimara' => 'integer',
'puntaje_quechua_aimara' => 'decimal:2',
'porcentaje_quechua_aimara' => 'decimal:2',
];
/*
|--------------------------------------------------------------------------
| RELACIONES
|--------------------------------------------------------------------------
*/
public function proceso()
{

@ -2,7 +2,6 @@
<footer class="modern-footer">
<div class="footer-container">
<!-- COLUMNA 1 -->
<div class="footer-col">
<div class="footer-logo">
<img src="/logotiny.png" alt="Logo UNA" />
@ -18,7 +17,6 @@
</p>
</div>
<!-- COLUMNA 2 -->
<div class="footer-col">
<h4>Admisión</h4>
<ul>
@ -29,7 +27,6 @@
</ul>
</div>
<!-- COLUMNA 3 -->
<div class="footer-col">
<h4>Programas</h4>
<ul>
@ -39,7 +36,6 @@
</ul>
</div>
<!-- COLUMNA 4 -->
<div class="footer-col">
<h4>Contacto</h4>
<ul>
@ -50,7 +46,6 @@
</div>
</div>
<!-- FOOTER BOTTOM -->
<div class="footer-bottom">
© {{ new Date().getFullYear() }} Universidad Nacional del Altiplano.
Todos los derechos reservados.
@ -62,7 +57,7 @@
</script>
<style scoped>
/* FUENTE */
.modern-footer,
.footer-col h4,
.footer-col li,
@ -71,14 +66,13 @@
font-family: "Times New Roman", Times, serif;
}
/* FOOTER BASE */
.modern-footer {
background: #f9fafb;
border-top: 1px solid #d1d5db;
margin-top: 60px;
}
/* CONTAINER */
.footer-container {
max-width: 1320px;
margin: auto;
@ -88,7 +82,6 @@
gap: 32px;
}
/* LOGO */
.footer-logo {
display: flex;
align-items: center;
@ -124,7 +117,6 @@
max-width: 360px;
}
/* COLUMNS */
.footer-col h4 {
margin-bottom: 12px;
font-size: 0.95rem;
@ -144,7 +136,6 @@
margin-bottom: 8px;
}
/* BOTTOM */
.footer-bottom {
border-top: 1px solid #e5e7eb;
padding: 14px 24px;
@ -153,7 +144,6 @@
color: #374151;
}
/* RESPONSIVE */
@media (max-width: 900px) {
.footer-container {
grid-template-columns: 1fr;

@ -1,4 +1,3 @@
<!-- ContenidoPrincipal.vue (refactorizado) -->
<template>
<NavbarModerno />
@ -39,11 +38,10 @@
import { ref, markRaw } from "vue"
import { message } from "ant-design-vue"
// Components
import NavbarModerno from '../components/nabvar.vue'
import FooterModerno from '../components/footer.vue'
// Secciones
import HeroSection from './WebPageSections/HeroSection.vue'
import ProcessSection from './WebPageSections/ProcessSection.vue'
import ConvocatoriasSection from './WebPageSections/ConvocatoriasSection.vue'
@ -55,7 +53,6 @@ import ContactSection from './WebPageSections/ContactSection.vue'
import PreinscripcionModal from './WebPageSections//modal/PreinscripcionModal.vue'
// Iconos
import {
MedicineBoxOutlined,
BuildOutlined,
@ -67,10 +64,8 @@ import {
UserOutlined,
} from "@ant-design/icons-vue"
// Estado
const preinscripcionModalVisible = ref(false)
// Datos (estáticos const, no ref)
const facultades = [
{
id: "1",
@ -218,7 +213,6 @@ const noticias = [
},
]
// Métodos
const scrollToConvocatoria = () => {
const el = document.getElementById("convocatorias")
el?.scrollIntoView({ behavior: "smooth", block: "start" })

@ -12,14 +12,11 @@
</p>
</div>
<!-- PRINCIPAL ARRIBA (UNA SOLA FILA) + SECUNDARIAS ABAJO -->
<div class="convocatorias-grid">
<!-- PRINCIPAL -->
<a-card class="main-convocatoria-card">
<div class="card-badge">Principal</div>
<div class="main-card-grid">
<!-- IZQUIERDA -->
<div class="main-card-text">
<div class="convocatoria-header">
<div>
@ -83,7 +80,7 @@
</div>
</div>
<!-- DERECHA -->
<div class="main-card-media">
<a-image
src="/images/extra.jpg"
@ -96,7 +93,7 @@
</a-card>
<div class="secondary-list">
<!-- CARD 1 -->
<a-card class="secondary-convocatoria-card">
<div class="convocatoria-header">
<div>
@ -119,7 +116,7 @@
</div>
</a-card>
<!-- CARD 2 -->
<a-card class="secondary-convocatoria-card">
<div class="convocatoria-header">
<div>
@ -159,14 +156,14 @@ import {
defineProps({
otrasConvocatorias: {
type: Array,
default: () => [], // para que no salga el warning si no mandas nada
default: () => [],
},
})
const emit = defineEmits(["show-modal", "open-preinscripcion"])
const handleConsultar = (c) => {
// Si llega función, la ejecuta; si no, abre un modal genérico
if (c && typeof c.onConsultar === "function") {
c.onConsultar()
return
@ -176,19 +173,16 @@ const handleConsultar = (c) => {
</script>
<style scoped>
/* =========================
CONVOCATORIAS (con fondo cuadriculado)
========================= */
.convocatorias-modern {
position: relative; /* necesario para ::before */
position: relative;
padding: 40px 0;
font-family: "Times New Roman", Times, serif;
background: #fbfcff; /* base clara */
overflow: hidden; /* evita desbordes del fondo */
background: #fbfcff;
overflow: hidden;
}
/* Fondo cuadriculado detrás del contenido */
.convocatorias-modern::before {
content: "";
position: absolute;
@ -196,7 +190,6 @@ const handleConsultar = (c) => {
pointer-events: none;
z-index: 0;
/* Cuadrícula suave (líneas cada 24px) */
background-image:
repeating-linear-gradient(
to right,
@ -216,7 +209,7 @@ const handleConsultar = (c) => {
opacity: 0.55;
}
/* Asegura que TODO el contenido vaya encima del fondo */
.section-container {
position: relative;
z-index: 1;
@ -225,9 +218,7 @@ const handleConsultar = (c) => {
padding: 0 24px;
}
/* =========================
HEADER
========================= */
.section-header {
text-align: center;
margin-bottom: 50px;
@ -260,19 +251,15 @@ const handleConsultar = (c) => {
border-radius: 999px;
}
/* =========================
GRID PRINCIPAL (1 columna)
========================= */
.convocatorias-grid {
display: grid;
grid-template-columns: 1fr; /* principal arriba, secundarias abajo */
grid-template-columns: 1fr;
gap: 24px;
align-items: start;
}
/* =========================
CARD PRINCIPAL
========================= */
.main-convocatoria-card {
position: relative;
border: none;
@ -296,7 +283,7 @@ const handleConsultar = (c) => {
font-weight: 700;
}
/* Grid interno del card principal: texto + imagen */
.main-card-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
@ -323,9 +310,7 @@ const handleConsultar = (c) => {
border-radius: 14px;
}
/* =========================
TEXTOS Y HEADER DENTRO DE CARDS
========================= */
.convocatoria-header {
display: flex;
justify-content: space-between;
@ -376,9 +361,7 @@ const handleConsultar = (c) => {
font-weight: 700;
}
/* =========================
ACCIONES RÁPIDAS
========================= */
.action-buttons-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@ -395,9 +378,6 @@ const handleConsultar = (c) => {
border-radius: 10px;
}
/* =========================
PREINSCRIPCIÓN
========================= */
.preinscripcion-section {
display: flex;
justify-content: space-between;
@ -418,12 +398,9 @@ const handleConsultar = (c) => {
border-radius: 12px;
}
/* =========================
SECUNDARIAS ABAJO
========================= */
.secondary-list {
display: grid;
grid-template-columns: repeat(2, 1fr); /* 2 columnas en desktop */
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
@ -444,16 +421,13 @@ const handleConsultar = (c) => {
margin-top: 12px;
}
/* =========================
RESPONSIVE
========================= */
@media (max-width: 992px) {
.section-title {
font-size: 2.1rem;
}
.main-card-grid {
grid-template-columns: 1fr; /* imagen baja */
grid-template-columns: 1fr;
}
.main-card-media {
@ -466,7 +440,7 @@ const handleConsultar = (c) => {
}
.secondary-list {
grid-template-columns: 1fr; /* 1 columna en tablet */
grid-template-columns: 1fr;
}
}

@ -1,7 +1,7 @@
<!-- components/hero/HeroSection.vue -->
<template>
<section class="hero" aria-label="Sección principal de admisión">
<!-- Fondo institucional (sin degradados) -->
<div class="hero-bg" aria-hidden="true">
<div class="hero-grid"></div>
<div class="hero-shape hero-shape-1"></div>
@ -9,7 +9,7 @@
</div>
<div class="hero-container">
<!-- Contenido -->
<div class="hero-content">
<div class="hero-badges">
<a-tag class="hero-tag">Convocatoria 2026</a-tag>
@ -45,7 +45,6 @@
</a-button>
</div>
<!-- Micro datos (opcional) -->
<div class="hero-metrics">
<div class="metric">
<span class="metric-value">44</span>
@ -118,21 +117,12 @@ defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
</script>
<style scoped>
/* =========================================================
TIPOGRAFÍA: Times (institucional)
Puedes mover esto a un estilo global si lo deseas.
========================================================= */
.hero,
.hero * {
font-family: "Times New Roman", Times, serif;
}
/* =========================================================
COLORES INSTITUCIONALES (AJÚSTALOS A TU MANUAL DE MARCA)
- primary: color principal (fondo)
- secondary: apoyo
- accent: énfasis (año/indicadores)
========================================================= */
.hero {
--inst-primary: #1a237e; /* AZUL institucional (ejemplo) */
--inst-secondary: #0f172a; /* oscuro de apoyo */
@ -162,12 +152,12 @@ defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
background-size: 52px 52px;
}
/* Formas planas (sin degradados) para dar profundidad */
.hero-shape {
position: absolute;
border-radius: 999px;
opacity: 0.16;
background: #ffffff; /* plano */
background: #ffffff;
}
.hero-shape-1 {
@ -195,7 +185,7 @@ defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
align-items: center;
}
/* ====== CONTENT ====== */
.hero-badges {
display: flex;
align-items: center;
@ -232,11 +222,11 @@ defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
}
.hero-year {
color: var(--inst-accent); /* énfasis institucional */
color: var(--inst-accent);
position: relative;
}
/* Subrayado institucional (sin degradados) */
.hero-year::after {
content: "";
position: absolute;
@ -275,7 +265,6 @@ defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
border: 1px solid rgba(255, 255, 255, 0.22);
font-weight: 800;
border-radius: 12px;
/* Botón principal con acento institucional (sin degradado) */
background: var(--inst-accent);
color: var(--inst-secondary);
}

@ -1,4 +1,4 @@
<!-- components/modalidades/ModalidadesSection.vue -->
<template>
<section class="modalidades-section">
<div class="section-container">
@ -8,10 +8,9 @@
</div>
<div class="modalidades-grid">
<!-- ORDINARIO -->
<a-card class="modalidad-card">
<div class="modalidad-icon bg-ordinario">
<!-- Si no quieres íconos, elimina esta línea -->
<CalendarOutlined />
</div>
@ -21,7 +20,6 @@
<a-tag color="green" class="modalidad-status">Disponible</a-tag>
</a-card>
<!-- EXTRAORDINARIO -->
<a-card class="modalidad-card">
<div class="modalidad-icon bg-extraordinario">
<StarOutlined />
@ -33,7 +31,6 @@
<a-tag color="blue" class="modalidad-status">Requisitos especiales</a-tag>
</a-card>
<!-- SEDES -->
<a-card class="modalidad-card">
<div class="modalidad-icon bg-sedes">
<EnvironmentOutlined />
@ -50,7 +47,7 @@
</template>
<script setup>
// Icons (Ant Design Vue)
import { CalendarOutlined, StarOutlined, EnvironmentOutlined } from "@ant-design/icons-vue";
</script>
@ -116,15 +113,15 @@ import { CalendarOutlined, StarOutlined, EnvironmentOutlined } from "@ant-design
font-size: 28px;
}
/* Colores fijos por modalidad (sin props) */
.bg-ordinario {
background: #22c55e; /* verde */
background: #22c55e;
}
.bg-extraordinario {
background: #3b82f6; /* azul */
background: #3b82f6;
}
.bg-sedes {
background: #8b5cf6; /* morado */
background: #8b5cf6;
}
.modalidad-card h4 {

@ -1,4 +1,3 @@
<!-- components/noticias/NoticiasSection.vue -->
<template>
<section class="news-section">
<div class="container">
@ -28,7 +27,7 @@
:sm="12"
:lg="8"
>
<!-- Ribbon (categoría) -->
<a-badge-ribbon
:text="noticia.categoria"
:color="noticia.tagColor || 'blue'"
@ -42,7 +41,6 @@
>
<div class="cover-overlay" />
<!-- Fecha pill -->
<div class="date-pill">
<CalendarOutlined />
<span>{{ noticia.fecha }}</span>
@ -50,20 +48,19 @@
</div>
</template>
<!-- Content -->
<a-space direction="vertical" size="small" class="content">
<a-typography-title
:level="4"
class="card-title"
:content="noticia.titulo"
:ellipsis="{ rows: 2, tooltip: noticia.titulo }"
/>
<a-typography-title
:level="4"
class="card-title"
:content="noticia.titulo"
:ellipsis="{ rows: 2, tooltip: noticia.titulo }"
/>
<a-typography-paragraph
class="desc"
:content="noticia.descripcion"
:ellipsis="{ rows: 3, tooltip: noticia.descripcion }"
/>
<a-typography-paragraph
class="desc"
:content="noticia.descripcion"
:ellipsis="{ rows: 3, tooltip: noticia.descripcion }"
/>
<div class="actions">
@ -97,7 +94,7 @@ defineProps({
</script>
<style scoped>
/* ===== Sección con fondo elegante tipo “paper grid” ===== */
.news-section {
position: relative;
padding: 88px 0;
@ -160,12 +157,12 @@ defineProps({
letter-spacing: -0.4px;
}
/* ===== Subtítulo delgado ===== */
.subtitle {
margin: 8px 0 0 !important;
text-align: center;
font-family: "Times New Roman", Times, serif; /* opcional, para que combine */
font-weight: 300; /* DELGADO */
font-family: "Times New Roman", Times, serif;
font-weight: 300;
color: rgba(0, 0, 0, 0.58);
line-height: 1.6;
font-size: 1.02rem;
@ -189,7 +186,7 @@ defineProps({
opacity: 0.6;
}
/* ===== Card ===== */
.card {
border: 0;
border-radius: 18px;
@ -205,12 +202,12 @@ defineProps({
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.12);
}
/* Ribbon spacing fix */
.card :deep(.ant-card-cover) {
margin: 0;
}
/* ===== Cover ===== */
.cover {
position: relative;
height: 200px;
@ -228,7 +225,7 @@ defineProps({
);
}
/* Fecha pill */
.date-pill {
position: absolute;
left: 14px;
@ -246,7 +243,7 @@ defineProps({
font-weight: 700;
}
/* ===== Content ===== */
.content {
width: 100%;
}
@ -265,7 +262,7 @@ defineProps({
font-size: 0.98rem;
}
/* Actions */
.actions {
display: flex;
justify-content: space-between;
@ -278,14 +275,14 @@ defineProps({
font-weight: 900;
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.news-section {
padding: 64px 0;
}
.header {
flex-direction: column; /* pone botón debajo si lo dejas */
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;

@ -35,10 +35,6 @@
<script setup>
import { ref, onMounted, onUnmounted } from "vue"
/**
* Si quieres mantenerlo fijo como antes, deja 2.
* Si luego quieres automático por fecha, te lo ajusto con tu cronograma real.
*/
const currentStep = 2
const isMobile = ref(false)
@ -89,7 +85,7 @@ onUnmounted(() => {
line-height: 1.4;
}
/* Card contenedora (se ve moderno sin fondo gris) */
.process-card {
border: 1px solid #e5e7eb;
border-radius: 14px;
@ -98,33 +94,32 @@ onUnmounted(() => {
background: #fff;
}
/* Steps compactos */
.modern-steps {
padding: 8px 8px;
}
/* ====== IMPORTANTE: evitar que se corten letras (sin "…") ====== */
.modern-steps :deep(.ant-steps-item-title) {
white-space: normal !important; /* permite salto de línea */
overflow: visible !important; /* no ocultar texto */
text-overflow: clip !important; /* sin "..." */
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
max-width: none !important;
}
.modern-steps :deep(.ant-steps-item-content) {
min-width: 0; /* clave para que wrap funcione en flex */
min-width: 0;
width: 100%;
}
.modern-steps :deep(.ant-steps-item-container) {
align-items: flex-start; /* título de 2 líneas se alinea bien */
align-items: flex-start;
}
.modern-steps :deep(.ant-steps-item) {
flex: 1 1 0; /* reparte espacio */
flex: 1 1 0;
}
/* Tipografías de steps */
.modern-steps :deep(.ant-steps-item-title) {
font-size: 0.95rem;
font-weight: 700;
@ -139,7 +134,7 @@ onUnmounted(() => {
line-height: 1.25;
}
/* Iconos */
.modern-steps :deep(.ant-steps-item-icon) {
width: 30px;
height: 30px;
@ -147,19 +142,19 @@ onUnmounted(() => {
font-size: 13px;
}
/* Línea */
.modern-steps :deep(.ant-steps-item-tail::after) {
height: 2px;
background: #dfe6e9;
}
/* Step activo */
.modern-steps :deep(.ant-steps-item-process .ant-steps-item-icon) {
background-color: #1e3a8a;
border-color: #1e3a8a;
}
/* Step terminado (mejor look) */
.modern-steps :deep(.ant-steps-item-finish .ant-steps-item-icon > .ant-steps-icon) {
color: #1e3a8a;
}
@ -167,7 +162,7 @@ onUnmounted(() => {
border-color: #1e3a8a;
}
/* Nota inferior */
.process-note {
display: flex;
align-items: center;
@ -185,7 +180,7 @@ onUnmounted(() => {
flex-shrink: 0;
}
/* Tablet */
@media (max-width: 992px) {
.section-title {
font-size: 1.85rem;
@ -195,7 +190,7 @@ onUnmounted(() => {
}
}
/* Mobile */
@media (max-width: 768px) {
.process-section {
padding: 24px 0;
@ -213,7 +208,6 @@ onUnmounted(() => {
padding: 4px 4px;
}
/* en vertical se ve mejor con un poquito más de alto */
.modern-steps :deep(.ant-steps-item-icon) {
width: 28px;
height: 28px;

@ -17,18 +17,16 @@
class="area-card"
@click="selectArea(a)"
>
<!-- Imagen -->
<div class="area-media">
<a-image :src="a.imagen" :preview="false" class="area-img" />
<a-tag class="area-tag" color="blue">{{ a.nombre }}</a-tag>
</div>
<!-- Contenido -->
<div class="area-body">
<h3 class="area-title">{{ a.nombre }}</h3>
<p class="area-desc">{{ a.descripcion }}</p>
<!-- Métricas: AntDV (Tag) -->
<div class="area-metrics">
<a-tag color="processing">Programas: {{ a.programas }}</a-tag>
<a-tag color="default">Enfoque: {{ a.enfoque }}</a-tag>
@ -92,10 +90,10 @@ const selectArea = (area) => {
</script>
<style scoped>
/* ✅ Fondo AntDV (sin cuadrados) */
.areas-section {
padding: 80px 0;
background: #f5f5f5; /* estilo Ant */
background: #f5f5f5;
font-family: "Times New Roman", Times, serif;
}
@ -114,14 +112,14 @@ const selectArea = (area) => {
margin: 0 0 10px;
font-size: 2.4rem;
font-weight: 700;
color: rgba(0, 0, 0, 0.88); /* Ant text */
color: rgba(0, 0, 0, 0.88);
}
.section-subtitle {
margin: 0 auto;
max-width: 680px;
font-size: 1.05rem;
color: rgba(0, 0, 0, 0.65); /* Ant secondary text */
color: rgba(0, 0, 0, 0.65);
line-height: 1.45;
}
@ -132,14 +130,14 @@ const selectArea = (area) => {
gap: 18px;
}
/* Card: AntDV, mínimo override */
.area-card {
border-radius: 12px;
overflow: hidden;
cursor: pointer;
}
/* Imagen */
.area-media {
position: relative;
height: 190px;
@ -153,7 +151,7 @@ const selectArea = (area) => {
display: block;
}
/* Tag arriba */
.area-tag {
position: absolute;
top: 12px;
@ -163,7 +161,7 @@ const selectArea = (area) => {
font-weight: 600;
}
/* Body */
.area-body {
padding: 14px 14px 16px;
}
@ -195,7 +193,7 @@ const selectArea = (area) => {
border-radius: 10px;
}
/* Responsive */
@media (max-width: 992px) {
.areas-grid {
grid-template-columns: 1fr;

@ -43,7 +43,7 @@
</template>
<style scoped>
/* ===== Sección ===== */
.stats-section {
position: relative;
padding: 70px 0;
@ -53,7 +53,7 @@
overflow: hidden;
}
/* Fondo cuadriculado suave encima del gradiente */
.stats-section::before {
content: "";
position: absolute;
@ -79,7 +79,7 @@
);
}
/* Brillo sutil decorativo (opcional) */
.stats-section::after {
content: "";
position: absolute;
@ -101,7 +101,6 @@
padding: 0 24px;
}
/* ===== Header ===== */
.section-header {
text-align: center;
margin-bottom: 34px;
@ -122,20 +121,20 @@
line-height: 1.45;
}
/* ===== Grid ===== */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
/* ===== Card ===== */
.stat-card {
padding: 22px 18px;
border-radius: 16px;
text-align: center;
/* Glass */
background: rgba(255, 255, 255, 0.10);
border: 1px solid rgba(255, 255, 255, 0.18);
backdrop-filter: blur(10px);
@ -157,7 +156,7 @@
gap: 6px;
}
/* Número destacado */
.stat-number {
font-size: 2.8rem;
font-weight: 900;
@ -176,7 +175,7 @@
opacity: 0.92;
}
/* ===== Responsive ===== */
@media (max-width: 992px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));

@ -1,7 +1,7 @@
<template>
<a-layout-header class="modern-header">
<div class="header-container">
<!-- LOGO -->
<div class="university-logo" @click="goTo('inicio')" role="button" tabindex="0">
<div class="logo-icon">
<img src="/logotiny.png" alt="Logo UNA" />
@ -12,7 +12,6 @@
</div>
</div>
<!-- DESKTOP NAV -->
<nav class="modern-nav desktop-only">
<a-menu
v-model:selectedKeys="selectedKeys"
@ -23,7 +22,6 @@
/>
</nav>
<!-- RIGHT ACTIONS (DESKTOP) -->
<div class="right-actions desktop-only">
<router-link
v-if="!authStore.isAuthenticated"
@ -44,13 +42,11 @@
</router-link>
</div>
<!-- MOBILE MENU BUTTON -->
<a-button class="mobile-menu-btn mobile-only" type="text" @click="drawerOpen = true">
</a-button>
</div>
<!-- MOBILE DRAWER -->
<a-drawer
title="Menú"
placement="right"
@ -85,7 +81,6 @@
:openKeys="mobileOpenKeys"
/>
<!-- AUTH (MOBILE) -->
<div class="drawer-auth">
<router-link
v-if="!authStore.isAuthenticated"
@ -193,7 +188,6 @@ const handleMenuClick = ({ key }) => {
}
const handleDrawerMenuClick = ({ key }) => {
// si clickeas un padre con children, no cierres el drawer
const isParent = navItems.value.some((i) => i.key === key && i.children)
if (isParent) return
@ -223,7 +217,7 @@ onUnmounted(() => {
</script>
<style scoped>
/* VARIABLES (mejor que :root dentro de scoped) */
:host {
--primary-color: #1e3a8a;
--secondary-color: #374151;
@ -231,7 +225,6 @@ onUnmounted(() => {
--bg-color: #ffffff;
}
/* FUENTE INSTITUCIONAL */
.modern-header,
.nav-menu-modern,
.logo-text h1,
@ -241,7 +234,6 @@ onUnmounted(() => {
font-family: "Times New Roman", Times, serif;
}
/* HEADER */
.modern-header {
background: rgba(255, 255, 255, 0.92) !important;
backdrop-filter: blur(10px);
@ -256,7 +248,6 @@ onUnmounted(() => {
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
/* CONTAINER */
.header-container {
max-width: 1320px;
margin: 0 auto;
@ -268,7 +259,6 @@ onUnmounted(() => {
width: 100%;
}
/* LOGO */
.university-logo {
display: flex;
align-items: center;
@ -312,7 +302,6 @@ onUnmounted(() => {
color: var(--secondary-color);
}
/* NAV DESKTOP */
.modern-nav {
display: flex;
justify-content: center;
@ -347,7 +336,6 @@ onUnmounted(() => {
border-bottom: 3px solid var(--primary-color) !important;
}
/* RIGHT ACTIONS */
.right-actions {
display: flex;
justify-content: flex-end;
@ -356,7 +344,6 @@ onUnmounted(() => {
text-decoration: none;
}
/* BOTÓN MÓVIL */
.mobile-menu-btn {
font-size: 24px;
color: #111827;
@ -368,7 +355,6 @@ onUnmounted(() => {
background: rgba(0, 0, 0, 0.04);
}
/* DRAWER */
.drawer-content {
display: flex;
flex-direction: column;
@ -427,7 +413,6 @@ onUnmounted(() => {
font-size: 14px;
}
/* Drawer auth */
.drawer-auth {
margin-top: auto;
padding: 16px 20px;
@ -435,7 +420,6 @@ onUnmounted(() => {
background: #fff;
}
/* RESPONSIVE HELPERS */
.desktop-only {
display: flex;
}

@ -6,13 +6,11 @@ import { useUserStore } from '../store/user'
import { useAuthStore as usePostulanteStore } from '../store/postulanteStore'
const routes = [
// Home
{ path: '/', component: Hello },
// Login usuarios/admins
{ path: '/login', component: Login, meta: { guest: true } },
// Login postulante
{
path: '/login-postulante',
name: 'login-postulante',
@ -20,7 +18,7 @@ const routes = [
meta: { guest: true },
},
// Portal postulante
{
path: '/portal-postulante',
name: 'portal-postulante',
@ -69,7 +67,7 @@ const routes = [
},
// Usuario normal
{
path: '/usuario/dashboard',
name: 'dashboard',
@ -77,7 +75,7 @@ const routes = [
meta: { requiresAuth: true, role: 'usuario' }
},
// Admin
{
path: '/admin/dashboard',
component: () => import('../views/administrador/layout/Layout.vue'),
@ -133,7 +131,7 @@ const routes = [
]
},
// Superadmin
{
path: '/superadmin/dashboard',
name: 'superadmin-dashboard',
@ -141,7 +139,7 @@ const routes = [
meta: { requiresAuth: true, role: 'superadmin' }
},
// Errores
{ path: '/unauthorized', name: 'Unauthorized', component: () => import('../views/403.vue') },
{ path: '/403', name: 'forbidden', component: () => import('../views/403.vue') }
]
@ -155,28 +153,25 @@ router.beforeEach((to, from, next) => {
const userStore = useUserStore()
const postulanteStore = usePostulanteStore()
// --- Rutas protegidas para usuarios/admin ---
if (to.meta.requiresAuth && !to.path.startsWith('/portal-postulante') && !userStore.isAuth) {
return next('/login')
}
// --- Rutas protegidas para postulante ---
if (to.meta.requiresAuth && to.path.startsWith('/portal-postulante') && !postulanteStore.isAuthenticated) {
return next('/login-postulante')
}
// --- Evitar que usuarios logueados vayan a login ---
if (to.meta.guest && !to.path.startsWith('/login-postulante') && userStore.isAuth) {
userStore.redirectByRole()
return
}
// --- Evitar que postulantes logueados vayan a login postulante ---
if (to.meta.guest && to.path === '/login-postulante' && postulanteStore.isAuthenticated) {
return next('/portal-postulante')
}
// --- Validar roles para usuarios/admins ---
if (to.meta.role && !userStore.hasRole(to.meta.role)) {
return next('/403')
}

@ -5,7 +5,7 @@ export const useAreaStore = defineStore('area', {
state: () => ({
areas: [],
area: null,
cursosDisponibles: [], // todos los cursos
cursosDisponibles: [],
cursosVinculados: [],
procesosDisponibles: [],
procesosVinculados: [],
@ -20,7 +20,7 @@ export const useAreaStore = defineStore('area', {
// filtros
filters: {
search: '',
activo: null, // true | false | null
activo: null,
},
loading: false,
@ -29,28 +29,25 @@ export const useAreaStore = defineStore('area', {
}),
getters: {
// Cursos ya vinculados (objetos completos)
cursosVinculadosCompletos: (state) => {
return state.cursosDisponibles.filter(curso =>
state.cursosVinculados.includes(curso.id)
);
},
// Cursos disponibles (no vinculados)
cursosDisponiblesFiltrados: (state) => {
return state.cursosDisponibles.filter(curso =>
!state.cursosVinculados.includes(curso.id)
);
},
// Procesos ya vinculados (objetos completos)
procesosVinculadosCompletos: (state) => {
return state.procesosDisponibles.filter(proceso =>
state.procesosVinculados.includes(proceso.id)
);
},
// Procesos disponibles (no vinculados)
procesosDisponiblesFiltrados: (state) => {
return state.procesosDisponibles.filter(proceso =>
!state.procesosVinculados.includes(proceso.id)
@ -59,10 +56,7 @@ export const useAreaStore = defineStore('area', {
},
actions: {
// =============================
// LISTAR ÁREAS (con filtros)
// GET /api/admin/areas
// =============================
async fetchAreas(params = {}) {
this.loading = true
this.errors = null
@ -88,10 +82,6 @@ export const useAreaStore = defineStore('area', {
}
},
// =============================
// MOSTRAR ÁREA
// GET /api/admin/areas/{id}
// =============================
async fetchArea(id) {
this.loading = true
this.errors = null
@ -106,10 +96,7 @@ export const useAreaStore = defineStore('area', {
}
},
// =============================
// CREAR ÁREA
// POST /api/admin/areas
// =============================
async createArea(payload) {
this.loading = true
this.errors = null
@ -128,10 +115,7 @@ export const useAreaStore = defineStore('area', {
}
},
// =============================
// ACTUALIZAR ÁREA
// PUT /api/admin/areas/{id}
// =============================
async updateArea(id, payload) {
this.loading = true
this.errors = null
@ -150,10 +134,7 @@ export const useAreaStore = defineStore('area', {
}
},
// =============================
// ACTIVAR / DESACTIVAR ÁREA
// PATCH /api/admin/areas/{id}/toggle
// =============================
async toggleArea(id) {
this.loading = true
this.errors = null
@ -167,7 +148,7 @@ export const useAreaStore = defineStore('area', {
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
}
@ -182,10 +163,7 @@ export const useAreaStore = defineStore('area', {
}
},
// =============================
// ELIMINAR ÁREA
// DELETE /api/admin/areas/{id}
// =============================
async deleteArea(id) {
this.loading = true
@ -200,10 +178,7 @@ export const useAreaStore = defineStore('area', {
}
},
// =============================
// CURSOS POR ÁREA
// GET /api/areas/{areaId}/cursos-disponibles
// =============================
async fetchCursosPorArea(areaId) {
this.loading = true
this.error = null
@ -226,10 +201,6 @@ export const useAreaStore = defineStore('area', {
}
},
// =============================
// VINCULAR CURSOS
// POST /api/areas/{areaId}/vincular-cursos
// =============================
async vincularCursos(areaId, cursosIds) {
this.loading = true
this.error = null
@ -253,10 +224,6 @@ export const useAreaStore = defineStore('area', {
}
},
// =============================
// DESVINCULAR CURSO
// POST /api/areas/{areaId}/desvincular-curso
// =============================
async desvincularCurso(areaId, cursoId) {
this.loading = true
this.error = null
@ -280,10 +247,6 @@ export const useAreaStore = defineStore('area', {
}
},
// =============================
// PROCESOS POR ÁREA
// GET /api/areas/{areaId}/procesos-disponibles
// =============================
async fetchProcesosPorArea(areaId) {
this.loading = true
this.error = null
@ -306,10 +269,6 @@ export const useAreaStore = defineStore('area', {
}
},
// =============================
// VINCULAR PROCESOS
// POST /api/areas/{areaId}/vincular-procesos
// =============================
async vincularProcesos(areaId, procesosIds) {
this.loading = true
this.error = null
@ -333,10 +292,6 @@ export const useAreaStore = defineStore('area', {
}
},
// =============================
// DESVINCULAR PROCESO
// POST /api/areas/{areaId}/desvincular-proceso
// =============================
async desvincularProceso(areaId, procesoId) {
this.loading = true
this.error = null
@ -360,9 +315,6 @@ export const useAreaStore = defineStore('area', {
}
},
// =============================
// ACCIONES DE LIMPIEZA
// =============================
setSearch(search) {
this.filters.search = search
},

@ -12,7 +12,6 @@ export const useExamenStore = defineStore('examenStore', {
error: null,
}),
actions: {
// 1. Obtener procesos disponibles para el postulante
async fetchProcesos() {
try {
this.cargando = true
@ -25,7 +24,6 @@ export const useExamenStore = defineStore('examenStore', {
}
},
// 2. Obtener áreas por proceso
async fetchAreas(proceso_id) {
try {
this.cargando = true
@ -40,7 +38,6 @@ export const useExamenStore = defineStore('examenStore', {
}
},
// 3. Crear examen (sin preguntas)
async crearExamen(area_proceso_id, pago = null) {
try {
this.cargando = true
@ -56,7 +53,7 @@ export const useExamenStore = defineStore('examenStore', {
}
},
// 4. Obtener examen actual
async fetchExamenActual() {
try {
this.cargando = true
@ -69,7 +66,6 @@ export const useExamenStore = defineStore('examenStore', {
}
},
// 5. Generar preguntas para un examen
async generarPreguntas(examenId) {
try {
this.cargando = true
@ -83,7 +79,6 @@ export const useExamenStore = defineStore('examenStore', {
}
},
// 6. Iniciar examen
async iniciarExamen(examenId) {
try {
this.cargando = true
@ -99,11 +94,10 @@ export const useExamenStore = defineStore('examenStore', {
}
},
// 7. Responder pregunta
async responderPregunta(preguntaId, respuesta) {
try {
const { data } = await api.post(`/examen/pregunta/${preguntaId}/responder`, { respuesta })
// Actualizar pregunta local si es necesario
const index = this.preguntas.findIndex(p => p.id === preguntaId)
if (index !== -1) this.preguntas[index].respuesta = respuesta
return data
@ -113,7 +107,6 @@ export const useExamenStore = defineStore('examenStore', {
}
},
// 8. Finalizar examen
async finalizarExamen(examenId) {
try {
const { data } = await api.post(`/examen/${examenId}/finalizar`)
@ -126,7 +119,6 @@ export const useExamenStore = defineStore('examenStore', {
}
},
// Limpiar estado
resetStore() {
this.procesos = []
this.areas = []

@ -1,25 +1,21 @@
// stores/authStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '../axiosPostulante'
export const useAuthStore = defineStore('auth', () => {
// Estado
const token = ref(localStorage.getItem('postulante_token') || null)
const postulante = ref(JSON.parse(localStorage.getItem('postulante_data') || 'null'))
const loading = ref(false)
const error = ref(null)
// Getters
const isAuthenticated = computed(() => !!token.value)
const userDni = computed(() => postulante.value?.dni || null)
const userEmail = computed(() => postulante.value?.email || null)
const userName = computed(() => postulante.value?.name || null)
const userId = computed(() => postulante.value?.id || null)
// Actions
// Registro
const register = async ({ name, email, password, password_confirmation, dni }) => {
try {
loading.value = true
@ -52,7 +48,6 @@ export const useAuthStore = defineStore('auth', () => {
}
}
// Login
const login = async ({ email, password, device_id = null }) => {
try {
loading.value = true
@ -79,7 +74,7 @@ export const useAuthStore = defineStore('auth', () => {
}
}
// Logout
const logout = async () => {
try {
if (token.value) {
@ -97,7 +92,6 @@ export const useAuthStore = defineStore('auth', () => {
}
}
// Check sesión
const checkAuth = async () => {
if (!token.value) return false
@ -119,20 +113,18 @@ export const useAuthStore = defineStore('auth', () => {
}
return {
// Estado
token,
postulante,
loading,
error,
// Getters
isAuthenticated,
userDni,
userEmail,
userName,
userId,
// Actions
register,
login,
logout,

@ -1,4 +1,3 @@
// store/pregunta.store.js
import { defineStore } from 'pinia'
import api from '../axios'
@ -11,9 +10,7 @@ export const usePreguntaStore = defineStore('pregunta', {
}),
actions: {
/* ===============================
OBTENER PREGUNTAS POR CURSO
=============================== */
async fetchPreguntasByCurso(cursoId, params = {}) {
this.loading = true
this.errors = null
@ -34,9 +31,6 @@ export const usePreguntaStore = defineStore('pregunta', {
}
},
/* ===============================
OBTENER UNA PREGUNTA
=============================== */
async fetchPregunta(id) {
this.loading = true
this.errors = null
@ -53,9 +47,6 @@ export const usePreguntaStore = defineStore('pregunta', {
}
},
/* ===============================
CREAR PREGUNTA (CON IMÁGENES)
=============================== */
async crearPregunta(formData) {
this.loading = true
this.errors = null
@ -108,15 +99,13 @@ async crearPregunta(formData) {
this.loading = false
}
},
/* ===============================
ACTUALIZAR PREGUNTA (SUMA IMÁGENES)
=============================== */
async actualizarPregunta(id, formData) {
this.loading = true
this.errors = null
try {
// Usar PUT directamente
const response = await api.put(`/admin/preguntas/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
@ -138,9 +127,7 @@ async actualizarPregunta(id, formData) {
}
},
/* ===============================
ELIMINAR PREGUNTA
=============================== */
async eliminarPregunta(id) {
this.loading = true
this.errors = null

@ -16,9 +16,6 @@ export const useProcesoStore = defineStore('proceso', {
}),
actions: {
/* =============================
| LISTAR
============================= */
async fetchProcesos(params = {}) {
this.loading = true
this.errors = null
@ -46,9 +43,6 @@ export const useProcesoStore = defineStore('proceso', {
}
},
/* =============================
| VER
============================= */
async fetchProceso(id) {
this.loading = true
this.errors = null
@ -65,9 +59,6 @@ export const useProcesoStore = defineStore('proceso', {
}
},
/* =============================
| CREAR
============================= */
async crearProceso(payload) {
this.loading = true
this.errors = null
@ -88,9 +79,6 @@ export const useProcesoStore = defineStore('proceso', {
}
},
/* =============================
| ACTUALIZAR
============================= */
async actualizarProceso(id, payload) {
this.loading = true
this.errors = null
@ -120,9 +108,7 @@ export const useProcesoStore = defineStore('proceso', {
}
},
/* =============================
| ELIMINAR
============================= */
async eliminarProceso(id) {
this.loading = true
this.errors = null
@ -139,9 +125,7 @@ export const useProcesoStore = defineStore('proceso', {
}
},
/* =============================
| TOGGLE ACTIVO
============================= */
async toggleActivo(id) {
this.loading = true
this.errors = null
@ -167,9 +151,6 @@ export const useProcesoStore = defineStore('proceso', {
}
},
/* =============================
| LIMPIAR
============================= */
clearProceso() {
this.proceso = null
this.errors = null

@ -1,4 +1,3 @@
// src/store/procesoAdmision.store.js
import { defineStore } from 'pinia'
import api from '../axios'
@ -7,7 +6,6 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
loading: false,
error: null,
// listado
procesos: [],
pagination: {
current_page: 1,
@ -16,10 +14,8 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
last_page: 1
},
// detalle
procesoActual: null,
// detalles del proceso actual
detalles: []
}),
@ -32,11 +28,7 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
'Ocurrió un error'
},
// =========================
// PROCESOS
// =========================
// GET /api/admin/procesos-admision
async fetchProcesos(params = {}) {
this.loading = true
this.error = null
@ -48,7 +40,7 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
}
})
// Laravel paginate
this.procesos = data.data ?? []
this.pagination.current_page = data.current_page ?? 1
this.pagination.per_page = data.per_page ?? this.pagination.per_page
@ -64,7 +56,6 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
}
},
// GET /api/admin/procesos-admision/{id}?include_detalles=1
async fetchProceso(id, { include_detalles = true } = {}) {
this.loading = true
this.error = null
@ -85,9 +76,8 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
}
},
// POST /api/admin/procesos-admision (multipart/form-data)
async createProceso(payload) {
// payload puede ser objeto normal o FormData
this.loading = true
this.error = null
try {
@ -109,7 +99,6 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
}
},
// PATCH /api/admin/procesos-admision/{id} (multipart/form-data)
async updateProceso(id, payload, { method = 'patch' } = {}) {
this.loading = true
this.error = null
@ -123,12 +112,10 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
})
// actualizar cache local
if (this.procesoActual?.id === id) {
this.procesoActual = data
}
// refrescar listado (opcional, pero recomendado)
await this.fetchProcesos({
page: this.pagination.current_page,
per_page: this.pagination.per_page
@ -143,14 +130,12 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
}
},
// DELETE /api/admin/procesos-admision/{id}
async deleteProceso(id) {
this.loading = true
this.error = null
try {
await api.delete(`/admin/procesos-admision/${id}`)
// limpiar caches
this.procesos = this.procesos.filter(p => p.id !== id)
if (this.procesoActual?.id === id) {
this.procesoActual = null
@ -166,11 +151,6 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
}
},
// =========================
// DETALLES (POR PROCESO)
// =========================
// GET /api/admin/procesos-admision/{procesoId}/detalles?tipo=requisitos
async fetchDetalles(procesoId, params = {}) {
this.loading = true
this.error = null
@ -189,7 +169,6 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
}
},
// POST /api/admin/procesos-admision/{procesoId}/detalles (multipart/form-data)
async createDetalle(procesoId, payload) {
this.loading = true
this.error = null
@ -210,7 +189,6 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
}
},
// GET /api/admin/detalles-admision/{id}
async fetchDetalleById(detalleId) {
this.loading = true
this.error = null
@ -225,7 +203,6 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
}
},
// PATCH /api/admin/detalles-admision/{id} (multipart/form-data)
async updateDetalle(detalleId, payload, { method = 'patch' } = {}) {
this.loading = true
this.error = null
@ -239,7 +216,7 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
headers: isFormData ? { 'Content-Type': 'multipart/form-data' } : undefined
})
// actualizar cache local si existe
const idx = this.detalles.findIndex(d => d.id === data.id)
if (idx >= 0) this.detalles[idx] = data
@ -252,7 +229,7 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
}
},
// DELETE /api/admin/detalles-admision/{id}
async deleteDetalle(detalleId, procesoId = null) {
this.loading = true
this.error = null
@ -261,7 +238,6 @@ export const useProcesoAdmisionStore = defineStore('procesoAdmision', {
this.detalles = this.detalles.filter(d => d.id !== detalleId)
// si me pasas procesoId, refresca desde backend
if (procesoId) await this.fetchDetalles(procesoId)
return true

@ -15,17 +15,13 @@ export const useReglaAreaProcesoStore = defineStore('reglaAreaProceso', {
}),
getters: {
// Obtener total de preguntas del proceso
totalPreguntasProceso: (state) => state.proceso?.cantidad_total_preguntas || 0,
// Verificar si se ha alcanzado el límite
limiteAlcanzado: (state) => state.totalPreguntasAsignadas >= (state.proceso?.cantidad_total_preguntas || 0),
},
actions: {
/**
* Establecer el area_proceso actual y cargar sus reglas
*/
async setAreaProceso(id) {
this.areaProcesoId = id
await this.cargarReglas()
@ -33,11 +29,11 @@ export const useReglaAreaProcesoStore = defineStore('reglaAreaProceso', {
async cargarAreaProcesos() {
this.cargando = true
try {
// Llamada al endpoint que devuelve áreas-proceso con nombres, reglas y cursos
const { data } = await api.get('/area-proceso/areasprocesos')
// Guardamos en el store
// Ahora contiene todas las áreas-proceso, con sus nombres y contadores
this.areaProcesos = data.areaProcesos
} catch (err) {
@ -49,9 +45,6 @@ async cargarAreaProcesos() {
},
/**
* Cargar reglas de un area_proceso
*/
async cargarReglas() {
if (!this.areaProcesoId) return
this.cargando = true
@ -70,9 +63,8 @@ async cargarAreaProcesos() {
}
},
/**
* Crear o actualizar una regla
*/
async guardarRegla(regla) {
if (!this.areaProcesoId) return
this.guardando = true
@ -80,18 +72,15 @@ async cargarAreaProcesos() {
try {
const { data } = await api.post(`/area-proceso/${this.areaProcesoId}/reglas`, regla)
// Actualizar la regla en el store si ya existe
const index = this.reglas.findIndex(r => r.curso_id === data.regla.curso_id)
if (index >= 0) {
this.reglas[index] = { ...this.reglas[index], ...data.regla }
} else {
this.reglas.push(data.regla)
}
// Ordenar por orden
this.reglas.sort((a, b) => (a.orden || 999) - (b.orden || 999))
// Actualizar contadores
this.totalPreguntasAsignadas = data.total_preguntas_asignadas
this.preguntasDisponibles = data.preguntas_disponibles
@ -108,9 +97,7 @@ async cargarAreaProcesos() {
}
},
/**
* Guardar múltiples reglas a la vez
*/
async guardarReglasMultiple(reglas) {
if (!this.areaProcesoId) return
this.guardando = true
@ -120,7 +107,6 @@ async cargarAreaProcesos() {
reglas: reglas
})
// Recargar las reglas desde el servidor para asegurar consistencia
await this.cargarReglas()
return { success: true, data }
@ -136,28 +122,23 @@ async cargarAreaProcesos() {
}
},
/**
* Editar una regla existente
*/
async editarRegla(reglaId, cambios) {
this.guardando = true
this.error = null
try {
const { data } = await api.put(`/reglas/${reglaId}`, cambios)
// Actualizar store
const index = this.reglas.findIndex(r => r.regla_id === reglaId)
if (index >= 0) {
this.reglas[index] = { ...this.reglas[index], ...data.regla }
}
// Actualizar contadores
if (data.total_preguntas_asignadas !== undefined) {
this.totalPreguntasAsignadas = data.total_preguntas_asignadas
this.preguntasDisponibles = this.proceso.cantidad_total_preguntas - this.totalPreguntasAsignadas
}
// Ordenar por orden
this.reglas.sort((a, b) => (a.orden || 999) - (b.orden || 999))
return { success: true, data }
@ -173,19 +154,15 @@ async cargarAreaProcesos() {
}
},
/**
* Eliminar una regla
*/
async eliminarRegla(reglaId) {
this.guardando = true
this.error = null
try {
const { data } = await api.delete(`/reglas/${reglaId}`)
// Eliminar del store
this.reglas = this.reglas.filter(r => r.regla_id !== reglaId)
// Actualizar contadores
if (data.total_preguntas_asignadas !== undefined) {
this.totalPreguntasAsignadas = data.total_preguntas_asignadas
this.preguntasDisponibles = this.proceso.cantidad_total_preguntas - this.totalPreguntasAsignadas
@ -204,11 +181,8 @@ async cargarAreaProcesos() {
}
},
/**
* Reordenar reglas
*/
async reordenarReglas(reglasOrdenadas) {
// Primero actualizar localmente
reglasOrdenadas.forEach((regla, index) => {
const reglaIndex = this.reglas.findIndex(r => r.curso_id === regla.curso_id)
if (reglaIndex >= 0) {
@ -216,15 +190,12 @@ async cargarAreaProcesos() {
}
})
// Ordenar array local
this.reglas.sort((a, b) => a.orden - b.orden)
// Si hay reglas con ID (existentes), actualizar en backend
const reglasConId = this.reglas.filter(r => r.regla_id)
if (reglasConId.length > 0) {
// Podrías hacer una actualización múltiple o individual
// Aquí asumimos que cada regla se actualiza individualmente
for (const regla of reglasConId) {
if (regla.regla_id) {
await this.editarRegla(regla.regla_id, { orden: regla.orden })
@ -233,18 +204,14 @@ async cargarAreaProcesos() {
}
},
/**
* Calcular preguntas disponibles para un curso específico
*/
calcularPreguntasDisponibles(cursoId, cantidadActual = 0) {
const otrasReglas = this.reglas.filter(r => r.curso_id !== cursoId)
const totalOtras = otrasReglas.reduce((sum, r) => sum + (r.cantidad_preguntas || 0), 0)
return Math.max(0, this.totalPreguntasProceso - totalOtras - cantidadActual)
},
/**
* Resetear store
*/
reset() {
this.areaProcesoId = null
this.proceso = null

@ -66,10 +66,9 @@ export const useUserStore = defineStore('user', {
api.defaults.headers.common['Authorization'] = `Bearer ${this.token}`
// Refresca usuario (roles / permisos)
await this.fetchUser()
// Redirección según rol
this.redirectByRole()
}
@ -135,7 +134,7 @@ export const useUserStore = defineStore('user', {
} else if (this.user.roles.includes('administrador')) {
router.push('/admin/dashboard')
} else {
router.push('/usuario/dashboard') // usuario normal
router.push('/usuario/dashboard')
}
}
},

@ -1,7 +1,6 @@
<!-- 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>
@ -19,7 +18,6 @@
</a-button>
</div>
<!-- Filtros -->
<div class="filters-section">
<a-input-search
v-model:value="searchText"
@ -58,18 +56,16 @@
</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"
@ -92,12 +88,9 @@
/>
</template>
<!-- Fecha Creación -->
<template v-if="column.key === 'created_at'">
{{ formatDate(record.created_at) }}
</template>
<!-- Cursos Count -->
<template v-if="column.key === 'cursos_count'">
<div class="count-badge" @click="showCourseModal(record)" style="cursor: pointer;">
<a-tag color="blue">{{ record.cursos_count || 0 }}</a-tag>
@ -105,7 +98,6 @@
</div>
</template>
<!-- Procesos Count -->
<template v-if="column.key === 'procesos_count'">
<div class="count-badge" @click="showProcessModal(record)" style="cursor: pointer;">
<a-tag color="green">{{ record.procesos_count || 0 }}</a-tag>
@ -113,7 +105,6 @@
</div>
</template>
<!-- Acciones -->
<template v-if="column.key === 'acciones'">
<a-space>
<a-button
@ -157,7 +148,6 @@
</a-table>
</div>
<!-- Modal Crear/Editar Área -->
<a-modal
v-model:open="modalVisible"
:title="isEditing ? 'Editar Área' : 'Nueva Área'"
@ -213,7 +203,7 @@
</a-form>
</a-modal>
<!-- Modal de Confirmación para Eliminar -->
<a-modal
v-model:open="deleteModalVisible"
title="Confirmar Eliminación"
@ -270,10 +260,8 @@ import {
} from '@ant-design/icons-vue'
import CourseModal from '../areas/CursosModal.vue'
import ProcesosModal from '../areas/ProcesosModal.vue'
// Store
const areaStore = useAreaStore()
// Estado
const modalVisible = ref(false)
const deleteModalVisible = ref(false)
const isEditing = ref(false)
@ -283,7 +271,6 @@ const searchText = ref('')
const statusFilter = ref(null)
const togglingId = ref(null)
// Formulario
const formState = reactive({
id: null,
nombre: '',
@ -298,9 +285,8 @@ const showCourseModal = (area) => {
selectedAreaForCourses.value = area
courseModalVisible.value = true
}
// Método para actualizar después de cambios en cursos
const handleCoursesUpdated = () => {
// Recargar las áreas para reflejar cambios
areaStore.fetchAreas()
message.success('Cursos actualizados correctamente')
}
@ -316,7 +302,7 @@ const handleProcessesUpdated = () => {
message.success('Procesos actualizados correctamente')
}
// Reglas de validación del formulario
const formRules = {
nombre: [
{ required: true, message: 'Por favor ingresa el nombre del área', trigger: 'blur' },
@ -328,7 +314,6 @@ const formRules = {
]
}
// Paginación para la tabla
const pagination = computed(() => ({
current: areaStore.pagination.current_page,
pageSize: areaStore.pagination.per_page,
@ -362,14 +347,14 @@ const columns = [
key: 'cursos_count',
width: 120,
align: 'center',
// customRender: ({ record }) => record.cursos_count || 0
},
{
title: 'Procesos', // NUEVA COLUMNA
title: 'Procesos',
key: 'procesos_count',
width: 120,
align: 'center',
// customRender: ({ record }) => record.procesos_count || 0
},
{
title: 'Estado',
@ -386,7 +371,7 @@ const columns = [
{
title: 'Acciones',
key: 'acciones',
width: 250, // Aumentar el ancho por los dos botones nuevos
width: 250,
align: 'center'
}
]
@ -402,7 +387,6 @@ 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
@ -619,7 +603,7 @@ onMounted(() => {
border-radius: 12px;
}
.areas-table {
min-width: 900px; /* Aumentar por las nuevas columnas */
min-width: 900px;
}
.areas-table :deep(.ant-table) {
border-radius: 12px;
@ -644,7 +628,6 @@ onMounted(() => {
padding: 4px 8px;
height: auto;
}
/* Para los botones de cursos y procesos */
.action-btn:nth-child(1),
.action-btn:nth-child(2) {
color: #1890ff;
@ -706,7 +689,6 @@ onMounted(() => {
color: #1f1f1f;
}
/* Responsive */
@media (max-width: 768px) {
.areas-header {
flex-direction: column;
@ -737,7 +719,6 @@ onMounted(() => {
/* En el scoped style */
.action-btn {
padding: 4px 8px;
height: auto;
@ -749,7 +730,6 @@ onMounted(() => {
margin-right: 4px;
}
/* Para el botón de cursos específicamente */
.action-btn:first-child {
color: #1890ff;
}

@ -101,7 +101,6 @@ const props = defineProps({
const emit = defineEmits(['update:open', 'courses-updated'])
// Usar el nombre correcto para v-model
const visible = computed({
get: () => props.open,
set: (value) => emit('update:open', value)
@ -115,7 +114,6 @@ const loading = computed(() => areaStore.loading)
const cursosDisponibles = computed(() => areaStore.cursosDisponibles || [])
const cursosVinculados = computed(() => areaStore.cursosVinculados || [])
// Cursos filtrados por búsqueda
const filteredCursos = computed(() => {
if (!searchText.value) return cursosDisponibles.value

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