From 53dbbe5382360238ff0aa65b2e02df12eb69eb5b Mon Sep 17 00:00:00 2001 From: elmer Date: Tue, 10 Feb 2026 22:46:41 -0500 Subject: [PATCH] last_commits --- .../ReglaAreaProcesoController.php | 322 ++++ .../app/Http/Controllers/ExamenController.php | 500 ++++++ .../Controllers/PostulanteAuthController.php | 269 ++++ back/app/Models/Area.php | 7 +- back/app/Models/AreaAdmision.php | 32 + back/app/Models/Examen.php | 69 + back/app/Models/Pago.php | 23 + back/app/Models/Postulante.php | 51 + back/app/Models/PreguntaAsignada.php | 39 + back/app/Models/Proceso.php | 19 +- back/app/Models/ProcesoAdmision.php | 35 + back/app/Models/ReglaAreaProceso.php | 32 + back/app/Models/ResultadoAdmision.php | 62 + back/app/Models/ResultadoAdmisionCarga.php | 148 ++ back/app/Services/ExamenService.php | 164 ++ back/config/auth.php | 10 + back/routes/api.php | 74 + front/package-lock.json | 47 +- front/public/logotiny.png | Bin 0 -> 48191 bytes front/src/axiosPostulante.js | 32 + front/src/components/Footer.vue | 167 ++ front/src/components/HelloWorld.vue | 1414 ----------------- .../Postulante/VincularAcademia.vue | 615 ------- .../components/SuperAdmin/AcademiasList.vue | 307 ---- front/src/components/WebPage.vue | 1140 +++++++++++++ front/src/components/nabvar.vue | 658 ++++++++ front/src/router/index.js | 109 +- front/src/store/areacursoStore.js | 74 - front/src/store/examen.store.js | 139 ++ front/src/store/postulanteStore.js | 141 ++ front/src/store/reglaAreaProceso.store.js | 257 +++ front/src/views/administrador/Dashboard.vue | 2 +- .../administrador/Procesos/ReglasList.vue | 769 +++++++++ .../views/administrador/areas/AreasList.vue | 8 +- .../src/views/administrador/layout/layout.vue | 15 + front/src/views/postulante/Dashboard.vue | 355 +++++ front/src/views/postulante/LoginView.vue | 1284 +++++++++++++++ front/src/views/postulante/Pagos.vue | 128 ++ front/src/views/postulante/PortalView.vue | 1210 ++++++++++++++ .../src/views/postulante/PreguntasExamen.vue | 664 ++++++++ front/src/views/postulante/Resultados.vue | 827 ++++++++++ 41 files changed, 9753 insertions(+), 2465 deletions(-) create mode 100644 back/app/Http/Controllers/Administracion/ReglaAreaProcesoController.php create mode 100644 back/app/Http/Controllers/ExamenController.php create mode 100644 back/app/Http/Controllers/PostulanteAuthController.php create mode 100644 back/app/Models/AreaAdmision.php create mode 100644 back/app/Models/Examen.php create mode 100644 back/app/Models/Pago.php create mode 100644 back/app/Models/Postulante.php create mode 100644 back/app/Models/PreguntaAsignada.php create mode 100644 back/app/Models/ProcesoAdmision.php create mode 100644 back/app/Models/ReglaAreaProceso.php create mode 100644 back/app/Models/ResultadoAdmision.php create mode 100644 back/app/Models/ResultadoAdmisionCarga.php create mode 100644 back/app/Services/ExamenService.php create mode 100644 front/public/logotiny.png create mode 100644 front/src/axiosPostulante.js create mode 100644 front/src/components/Footer.vue delete mode 100644 front/src/components/HelloWorld.vue delete mode 100644 front/src/components/Postulante/VincularAcademia.vue delete mode 100644 front/src/components/SuperAdmin/AcademiasList.vue create mode 100644 front/src/components/WebPage.vue create mode 100644 front/src/components/nabvar.vue delete mode 100644 front/src/store/areacursoStore.js create mode 100644 front/src/store/examen.store.js create mode 100644 front/src/store/postulanteStore.js create mode 100644 front/src/store/reglaAreaProceso.store.js create mode 100644 front/src/views/administrador/Procesos/ReglasList.vue create mode 100644 front/src/views/postulante/Dashboard.vue create mode 100644 front/src/views/postulante/LoginView.vue create mode 100644 front/src/views/postulante/Pagos.vue create mode 100644 front/src/views/postulante/PortalView.vue create mode 100644 front/src/views/postulante/PreguntasExamen.vue create mode 100644 front/src/views/postulante/Resultados.vue diff --git a/back/app/Http/Controllers/Administracion/ReglaAreaProcesoController.php b/back/app/Http/Controllers/Administracion/ReglaAreaProcesoController.php new file mode 100644 index 0000000..f80fdf9 --- /dev/null +++ b/back/app/Http/Controllers/Administracion/ReglaAreaProcesoController.php @@ -0,0 +1,322 @@ +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 + ->join('areas as a', 'ap.area_id', '=', 'a.id') + ->join('procesos as p', 'ap.proceso_id', '=', 'p.id') + ->select( + 'ap.id', + 'ap.area_id', + 'ap.proceso_id', + 'a.nombre as area_nombre', + 'p.nombre as proceso_nombre', + DB::raw('COUNT(DISTINCT r.id) as reglas_count'), + DB::raw('COUNT(DISTINCT c.id) as cursos_count') + ) + ->groupBy('ap.id', 'ap.area_id', 'ap.proceso_id', 'a.nombre', 'p.nombre') + ->get(); + + return response()->json([ + 'areaProcesos' => $areasProcesos + ]); + } + + + + + +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') + ->where('ap.id', $areaProcesoId) + ->select('ap.id as area_proceso_id', 'a.id as area_id', 'a.nombre as area_nombre', + 'p.id as proceso_id', 'p.nombre as proceso_nombre', 'p.duracion as cantidad_total_preguntas') + ->first(); + + if (!$areaProceso) { + 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 [ + 'curso_id' => $curso->curso_id, + 'nombre' => $curso->nombre, + 'regla_id' => $regla->id ?? null, + 'orden' => $regla->orden ?? null, + 'cantidad_preguntas' => $regla->cantidad_preguntas ?? null, + 'nivel_dificultad' => $regla->nivel_dificultad ?? 'medio', + 'ponderacion' => $regla->ponderacion ?? null, + ]; + })->sortBy('orden')->values(); + + return response()->json([ + 'area_proceso_id' => $areaProceso->area_proceso_id, + 'proceso' => [ + 'id' => $areaProceso->proceso_id, + 'nombre' => $areaProceso->proceso_nombre, + 'cantidad_total_preguntas' => $areaProceso->cantidad_total_preguntas, + ], + 'cursos' => $reglas, + 'total_preguntas_asignadas' => $reglasExistentes->sum('cantidad_preguntas'), + ]); +} + +public function store(Request $request, $areaProcesoId) +{ + $request->validate([ + 'curso_id' => 'required|exists:cursos,id', + 'cantidad_preguntas' => 'required|integer|min:0', + 'orden' => 'required|integer|min:1', + 'nivel_dificultad' => 'nullable|string|in:bajo,medio,alto', + '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) + ->select('p.cantidad_pregunta') + ->first(); + + if (!$areaProceso) { + return response()->json([ + 'error' => 'No se encontró el proceso asociado al área' + ], 404); + } + + $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) + ->sum('cantidad_preguntas'); + + $totalNuevo = $totalAsignado + $request->cantidad_preguntas; + + if ($totalNuevo > $totalPreguntasProceso) { + return response()->json([ + 'error' => 'Excede la cantidad total de preguntas del proceso. Disponible: ' . + ($totalPreguntasProceso - $totalAsignado) + ], 422); + } + + // 🔹 Insertar o actualizar UNA regla + DB::table('reglas_area_proceso')->updateOrInsert( + [ + 'area_proceso_id' => $areaProcesoId, + 'curso_id' => $request->curso_id, + ], + [ + 'cantidad_preguntas' => $request->cantidad_preguntas, + 'orden' => $request->orden, + 'nivel_dificultad' => $request->nivel_dificultad ?? 'medio', + 'ponderacion' => $request->ponderacion ?? 0, + 'updated_at' => now(), + 'created_at' => now(), + ] + ); + + $totalAsignadoFinal = DB::table('reglas_area_proceso') + ->where('area_proceso_id', $areaProcesoId) + ->sum('cantidad_preguntas'); + + return response()->json([ + 'success' => true, + 'message' => 'Regla guardada correctamente', + 'total_preguntas_asignadas' => $totalAsignadoFinal, + 'preguntas_disponibles' => $totalPreguntasProceso - $totalAsignadoFinal, + ]); +} + + +public function update(Request $request, $reglaId) +{ + $request->validate([ + 'cantidad_preguntas' => 'required|integer|min:0', + 'orden' => 'required|integer|min:1', + 'nivel_dificultad' => 'nullable|string|in:bajo,medio,alto', + 'ponderacion' => 'nullable|numeric|min:0|max:100', + ]); + + // 🔹 Obtener la regla actual + $regla = DB::table('reglas_area_proceso') + ->where('id', $reglaId) + ->first(); + + if (!$regla) { + return response()->json([ + 'error' => 'La regla no existe' + ], 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) + ->select('p.cantidad_pregunta') + ->first(); + + if (!$areaProceso) { + return response()->json([ + 'error' => 'No se encontró el proceso asociado al área' + ], 404); + } + + $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) + ->sum('cantidad_preguntas'); + + $totalNuevo = $totalAsignado + $request->cantidad_preguntas; + + if ($totalNuevo > $totalPreguntasProceso) { + return response()->json([ + 'error' => 'Excede la cantidad total de preguntas del proceso. Disponible: ' . + ($totalPreguntasProceso - $totalAsignado) + ], 422); + } + + // 🔹 Actualizar la regla + DB::table('reglas_area_proceso') + ->where('id', $reglaId) + ->update([ + 'cantidad_preguntas' => $request->cantidad_preguntas, + 'orden' => $request->orden, + 'nivel_dificultad' => $request->nivel_dificultad ?? 'medio', + 'ponderacion' => $request->ponderacion ?? 0, + 'updated_at' => now(), + ]); + + $totalAsignadoFinal = DB::table('reglas_area_proceso') + ->where('area_proceso_id', $regla->area_proceso_id) + ->sum('cantidad_preguntas'); + + return response()->json([ + 'success' => true, + 'message' => 'Regla actualizada correctamente', + 'total_preguntas_asignadas' => $totalAsignadoFinal, + 'preguntas_disponibles' => $totalPreguntasProceso - $totalAsignadoFinal, + ]); +} + + /** + * Eliminar una regla + */ + public function destroy($reglaId) + { + $regla = ReglaAreaProceso::findOrFail($reglaId); + $areaProcesoId = $regla->area_proceso_id; + + $regla->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Regla eliminada correctamente', + 'total_preguntas_asignadas' => ReglaAreaProceso::where('area_proceso_id', $areaProcesoId) + ->sum('cantidad_preguntas'), + ]); + } + + + +public function storeMultiple(Request $request, $areaProcesoId) +{ + $request->validate([ + 'reglas' => 'required|array', + 'reglas.*.curso_id' => 'required|exists:cursos,id', + 'reglas.*.cantidad_preguntas' => 'required|integer|min:0', + 'reglas.*.orden' => 'required|integer|min:1', + 'reglas.*.nivel_dificultad' => 'nullable|string|in:bajo,medio,alto', + '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) + ->select('p.cantidad_pregunta') + ->first(); + + $totalPreguntasProceso = $areaProceso->cantidad_pregunta ?? 0; + + // Validar total de preguntas asignadas + $totalNuevo = collect($request->reglas)->sum('cantidad_preguntas'); + if ($totalNuevo > $totalPreguntasProceso) { + return response()->json([ + 'error' => 'Excede la cantidad total de preguntas del proceso. Máximo permitido: ' . $totalPreguntasProceso + ], 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, + 'curso_id' => $r['curso_id'], + 'cantidad_preguntas' => $r['cantidad_preguntas'], + 'orden' => $r['orden'], + 'nivel_dificultad' => $r['nivel_dificultad'] ?? 'medio', + 'ponderacion' => $r['ponderacion'] ?? 0, + 'created_at' => now(), + 'updated_at' => now(), + ]; + })->toArray(); + + if (!empty($insertData)) { + DB::table('reglas_area_proceso')->insert($insertData); + } + + return response()->json([ + 'success' => true, + 'message' => 'Reglas guardadas correctamente', + 'total_preguntas_asignadas' => $totalNuevo, + 'preguntas_disponibles' => $totalPreguntasProceso - $totalNuevo, + ]); +} + + +} \ No newline at end of file diff --git a/back/app/Http/Controllers/ExamenController.php b/back/app/Http/Controllers/ExamenController.php new file mode 100644 index 0000000..a6dff66 --- /dev/null +++ b/back/app/Http/Controllers/ExamenController.php @@ -0,0 +1,500 @@ +examenService = $examenService; + } +public function procesoexamen(Request $request) +{ + $postulante = $request->user(); + + $procesos = Proceso::where('activo', 1) + ->whereNotExists(function ($q) use ($postulante) { + $q->select(\DB::raw(1)) + ->from('examenes') + ->join('area_proceso', 'area_proceso.id', '=', 'examenes.area_proceso_id') + ->whereColumn('area_proceso.proceso_id', 'procesos.id') + ->where('examenes.postulante_id', $postulante->id); + }) + ->select('id', 'nombre', 'requiere_pago') + ->get(); + + return response()->json($procesos); +} + + +public function areas(Request $request) +{ + $procesoId = $request->query('proceso_id'); + + $areas = Area::select('areas.id', 'areas.nombre') + ->whereHas('procesos', function ($query) use ($procesoId) { + $query->where('procesos.id', $procesoId) + ->where('procesos.activo', 1); + }) + ->with(['procesos' => function ($q) use ($procesoId) { + $q->where('procesos.id', $procesoId) + ->select('procesos.id') + ->withPivot('id', 'proceso_id', 'area_id'); + }]) + ->get() + ->map(function ($area) { + $pivot = $area->procesos->first()->pivot; + + return [ + 'area_id' => $area->id, + 'nombre' => $area->nombre, + 'area_proceso_id' => $pivot->id, // 🔥 CLAVE + ]; + }); + + return response()->json($areas); +} + + + +public function crearExamen(Request $request) +{ + $postulante = $request->user(); + + $request->validate([ + 'area_proceso_id' => 'required|exists:area_proceso,id', + ]); + + // 🔥 Obtener TODO desde el pivot + $areaProceso = \DB::table('area_proceso') + ->join('procesos', 'procesos.id', '=', 'area_proceso.proceso_id') + ->join('areas', 'areas.id', '=', 'area_proceso.area_id') + ->where('area_proceso.id', $request->area_proceso_id) + ->select( + 'area_proceso.id', + 'area_proceso.area_id', + 'area_proceso.proceso_id', + 'procesos.requiere_pago' + ) + ->first(); + + if (!$areaProceso) { + return response()->json(['message' => 'Relación área-proceso inválida'], 400); + } + + $yaDioProceso = Examen::join('area_proceso', 'area_proceso.id', '=', 'examenes.area_proceso_id') + ->where('examenes.postulante_id', $postulante->id) + ->where('area_proceso.proceso_id', $areaProceso->proceso_id) + ->exists(); + + if ($yaDioProceso) { + return response()->json([ + 'message' => 'Ya rendiste un examen para este proceso' + ], 400); + } + + $pagado = 0; + $pagoId = null; + + // 💰 Validación de pago + if ($areaProceso->requiere_pago) { + $request->validate([ + 'tipo_pago' => 'required|in:pyto_peru,banco_nacion,caja', + 'codigo_pago' => 'required', + ]); + + $response = $this->validarPago( + $request->tipo_pago, + $request->codigo_pago, + $postulante->dni + ); + + if (!$response['estado']) { + return response()->json(['message' => 'Pago inválido'], 400); + } + + $pago = Pago::firstOrCreate( + [ + 'codigo_pago' => $request->codigo_pago, + 'tipo_pago' => $request->tipo_pago, + ], + [ + 'postulante_id' => $postulante->id, + 'monto' => $response['monto'], + 'fecha_pago' => $response['fecha_pago'], + ] + ); + + if ($pago->utilizado) { + return response()->json(['message' => 'Pago ya utilizado'], 400); + } + + $pago->update(['utilizado' => true]); + + $pagado = 1; + $pagoId = $pago->id; + } + + // 🧠 Crear / actualizar examen + $examen = Examen::updateOrCreate( + ['postulante_id' => $postulante->id, + 'area_proceso_id' => $areaProceso->id, // 🔥 pivot + 'pagado' => $pagado, + 'tipo_pago' => $request->tipo_pago ?? null, + 'pago_id' => $pagoId, + ] + ); + + return response()->json([ + 'success' => true, + 'examen_id' => $examen->id, + 'mensaje' => 'Examen creado correctamente', + ]); +} + + + public function validarPago($tipoPago, $codigoPago, $dni) + { + return match ($tipoPago) { + 'pyto_peru' => $this->validarPagoPytoPeru($codigoPago, $dni), + 'banco_nacion' => $this->validarPagoBancoNacion($codigoPago, $dni), + 'caja' => $this->validarPagoCaja($codigoPago, $dni), + default => ['estado' => false], + }; + } + + private function validarPagoBancoNacion($secuencia, $dni) + { + $url = 'https://inscripciones.admision.unap.edu.pe/api/get-pagos-banco-secuencia'; + + $response = Http::post($url, [ + 'secuencia' => $secuencia, + ]); + + if (!$response->successful()) { + \Log::error('Error en la solicitud a la API', ['status' => $response->status(), 'body' => $response->body()]); + return ['estado' => false, 'message' => 'Error en la solicitud a la API']; + } + + $data = $response->json(); + + if (isset($data['estado']) && $data['estado'] === true) { + foreach ($data['datos'] as $pago) { + if (isset($pago['dni']) && trim($pago['dni']) == trim($dni)) { + return [ + 'estado' => true, + 'monto' => $pago['imp_pag'], + 'fecha_pago' => $pago['fch_pag'], + ]; + } + } + } + + return ['estado' => false, 'message' => 'Datos no válidos o no encontrados']; + } + + private function validarPagoPytoPeru($authorizationCode, $dni) + { + $url = "https://service2.unap.edu.pe/PAYMENTS_MNG/v1/{$dni}/8/"; + $response = Http::get($url); + + if ($response->successful()) { + $data = $response->json(); + + + if (isset($data['data'][0]['autorizationCode']) && $data['data'][0]['autorizationCode'] === $authorizationCode) { + return [ + 'estado' => true, + 'monto' => $data['data'][0]['total'], + 'fecha_pago' => $data['data'][0]['confirmedDate'], + ]; + } + } + + return ['estado' => false, 'message' => 'El código de autorización no coincide.']; + } + + private function validarPagoCaja($codigoPago, $dni) + { + $url = "https://inscripciones.admision.unap.edu.pe/api/get-pago-caja/{$dni}/{$codigoPago}"; + + $response = Http::get($url); + + if (!$response->successful()) { + \Log::error('Error API Caja', [ + 'status' => $response->status(), + 'body' => $response->body() + ]); + return ['estado' => false, 'message' => 'Error en la API de Caja']; + } + + $data = $response->json(); + + if ( + isset($data['paymentTitle'], $data['paymentAmount'], $data['paymentDatetime']) && + trim($data['paymentTitle']) === trim($codigoPago) + ) { + return [ + 'estado' => true, + 'monto' => (float) $data['paymentAmount'], + 'fecha_pago' => $data['paymentDatetime'], + ]; + } + + return ['estado' => false, 'message' => 'Pago no válido o no encontrado']; + } + +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') + ->where('examenes.postulante_id', $postulante->id) + ->select( + 'examenes.id', + 'examenes.pagado', + 'examenes.tipo_pago', + 'examenes.intentos', // intentos del examen + '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 + ) + ->latest('examenes.created_at') // solo el más reciente + ->first(); + + if (!$examen) { + return response()->json([ + 'success' => true, + 'mensaje' => 'No tienes exámenes asignados actualmente', + 'examen' => null + ]); + } + + return response()->json([ + 'success' => true, + 'examen' => [ + 'id' => $examen->id, + 'intentos' => $examen->intentos, + 'intentos_maximos' => $examen->proceso_intentos_maximos, + 'proceso' => [ + 'id' => $examen->proceso_id, + 'nombre' => $examen->proceso_nombre, + ], + 'area' => [ + 'id' => $examen->area_id, + 'nombre' => $examen->area_nombre, + ], + 'pagado' => $examen->pagado, + 'tipo_pago' => $examen->tipo_pago ?? null, + ] + ]); +} + + + + +/** + * 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([ + 'success' => false, + 'message' => 'No autorizado' + ], 403); + } + + // Si YA tiene preguntas, no generar nuevas, solo confirmar éxito + if ($examen->preguntasAsignadas()->exists()) { + return response()->json([ + 'success' => true, + 'message' => 'El examen ya tiene preguntas generadas', + 'ya_tiene_preguntas' => true, + 'total_preguntas' => $examen->preguntasAsignadas()->count() + ]); + } + + // Si NO tiene preguntas, generar usando el servicio + $resultado = $this->examenService->generarPreguntasExamen($examen); + + if (!$resultado['success']) { + return response()->json($resultado, 400); + } + + return response()->json([ + 'success' => true, + 'message' => 'Preguntas generadas exitosamente', + 'ya_tiene_preguntas' => false, + 'total_preguntas' => $examen->preguntasAsignadas()->count() + ]); +} + + + /** + * 4. INICIAR EXAMEN (marcar hora inicio) + */ +public function iniciarExamen(Request $request) +{ + $request->validate([ + 'examen_id' => 'required|exists:examenes,id' + ]); + + $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([ + 'success' => false, + 'message' => 'No autorizado' + ], 403); + } + + // Traer datos del área-proceso directamente desde la DB + $areaProceso = \DB::table('area_proceso') + ->join('procesos', 'area_proceso.proceso_id', '=', 'procesos.id') + ->join('areas', 'area_proceso.area_id', '=', 'areas.id') + ->where('area_proceso.id', $examen->area_proceso_id) + ->select( + 'procesos.id as proceso_id', + 'procesos.nombre as proceso_nombre', + 'procesos.duracion as proceso_duracion', + 'procesos.intentos_maximos as proceso_intentos_maximos', + 'areas.nombre as area_nombre' + ) + ->first(); + + // Verificar que tenga preguntas + if (!$examen->preguntasAsignadas()->exists()) { + return response()->json([ + 'success' => false, + 'message' => 'El examen no tiene preguntas generadas' + ], 400); + } + + // Verificar que no exceda el número máximo de intentos + if ($areaProceso && $examen->intentos >= $areaProceso->proceso_intentos_maximos) { + return response()->json([ + 'success' => false, + 'message' => 'Has alcanzado el número máximo de intentos para este proceso' + ], 403); + } + + // Marcar hora inicio si no está iniciado + if (!$examen->hora_inicio) { + $examen->update([ + 'hora_inicio' => now(), + 'estado' => 'en_progreso', + 'intentos' => $examen->intentos + 1 + ]); + } + + // Obtener preguntas con toda la información + $preguntas = $this->examenService->obtenerPreguntasExamen($examen); + + return response()->json([ + 'success' => true, + 'examen' => [ + 'id' => $examen->id, + 'estado' => $examen->estado, + 'hora_inicio' => $examen->hora_inicio, + 'intentos' => $examen->intentos, + 'intentos_maximos' => $areaProceso->proceso_intentos_maximos ?? null, + 'proceso' => $areaProceso->proceso_nombre ?? null, + 'duracion' => $areaProceso->proceso_duracion ?? null, + 'area' => $areaProceso->area_nombre ?? null + ], + 'preguntas' => $preguntas + ]); +} + + + /** + * 5. RESPONDER PREGUNTA + */ + public function responderPregunta($preguntaAsignadaId, Request $request) + { + $request->validate([ + 'respuesta' => 'required|string' + ]); + + $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([ + 'success' => false, + 'message' => 'No autorizado' + ], 403); + } + + // Guardar respuesta + $resultado = $this->examenService->guardarRespuesta( + $preguntaAsignada, + $request->respuesta + ); + + 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([ + 'success' => false, + 'message' => 'No autorizado' + ], 403); + } + + $this->examenService->finalizarExamen($examen); + + return response()->json([ + 'success' => true, + 'message' => 'Examen finalizado exitosamente' + ]); + } + + +} \ No newline at end of file diff --git a/back/app/Http/Controllers/PostulanteAuthController.php b/back/app/Http/Controllers/PostulanteAuthController.php new file mode 100644 index 0000000..7e07c69 --- /dev/null +++ b/back/app/Http/Controllers/PostulanteAuthController.php @@ -0,0 +1,269 @@ +all(), [ + 'name' => 'required|string|max:255|regex:/^[\pL\s\-]+$/u', + 'email' => 'required|email|unique:postulantes,email|max:255', + 'password' => [ + 'required', + 'string', + 'min:8', + 'confirmed', + 'regex:/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/' + ], + 'dni' => 'required|string|max:20|unique:postulantes,dni', + ], [ + 'password.regex' => 'La contraseña debe contener al menos una mayúscula, una minúscula, un número y un carácter especial.', + 'name.regex' => 'El nombre solo puede contener letras y espacios.', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'errors' => $validator->errors()], 422); + } + + $postulante = Postulante::create([ + 'name' => strip_tags(trim($request->name)), + 'email' => strtolower(trim($request->email)), + 'password' => $request->password, + 'dni' => $request->dni, + ]); + + Log::info('Postulante registrado', ['id' => $postulante->id, 'dni' => $postulante->dni]); + + return response()->json(['success' => true, 'message' => 'Postulante registrado exitosamente', 'postulante' => $postulante], 201); + } + + public function login(Request $request) + { + $validator = Validator::make($request->all(), [ + 'email' => 'required|email|max:255', + 'password' => 'required|string', + ]); + + if ($validator->fails()) { + return response()->json(['success' => false, 'errors' => $validator->errors()], 422); + } + + $email = strtolower(trim($request->email)); + + $postulante = Postulante::where('email', $email)->first(); + + if (!$postulante || !Hash::check($request->password, $postulante->password)) { + Log::warning('Intento de login fallido', ['email' => $email]); + return response()->json([ + 'success' => false, + 'message' => 'Credenciales inválidas' + ], 401); + } + + $deviceId = $request->header('Device-Id') ?? Str::random(10); + + // Revocar tokens antiguos + $postulante->tokens()->delete(); + + // Crear token + $token = $postulante + ->createToken($deviceId, ['*'], now()->addHour()) + ->plainTextToken; + + $postulante->update([ + 'device_id' => $deviceId, + 'last_activity' => now() + ]); + + Log::info('Login exitoso', ['id' => $postulante->id]); + + return response()->json([ + 'success' => true, + 'message' => 'Login exitoso', + 'postulante' => [ + 'id' => $postulante->id, + 'name' => $postulante->name, + 'email' => $postulante->email, + 'dni' => $postulante->dni + ], + 'token' => $token, + 'token_type' => 'Bearer', + 'expires_in' => 3600 + ]); + } + + /** + * Logout + */ + public function logout(Request $request) + { + $postulante = $request->user(); + if ($postulante) { + $postulante->tokens()->delete(); + $postulante->device_id = null; + $postulante->last_activity = null; + $postulante->save(); + } + + return response()->json(['success' => true, 'message' => 'Sesión cerrada correctamente']); + } + + /** + * Información del postulante logueado + */ + public function me(Request $request) + { + $postulante = $request->user(); + if (!$postulante) { + return response()->json(['success' => false, 'message' => 'No autenticado'], 401); + } + + // Actualizar última actividad al hacer request + $postulante->update(['last_activity' => now()]); + + return response()->json([ + 'success' => true, + 'postulante' => [ + 'id' => $postulante->id, + 'name' => $postulante->name, + 'email' => $postulante->email, + 'dni' => $postulante->dni + ] + ]); + } + + +public function obtenerPagosPostulante(Request $request) + { + $postulante = $request->user(); // o Auth::guard('postulante')->user(); + + if (!$postulante) { + return response()->json([ + 'success' => false, + 'message' => 'No autenticado' + ], 401); + } + + $dni = trim($postulante->dni); + + $pagos = []; + + // =============================== + // 1️⃣ PAGOS PYTO PERÚ + // =============================== + $urlPyto = "https://service2.unap.edu.pe/PAYMENTS_MNG/v1/{$dni}/8/"; + $responsePyto = Http::get($urlPyto); + + if ($responsePyto->successful()) { + $dataPyto = $responsePyto->json(); + + if (!empty($dataPyto['data'])) { + foreach ($dataPyto['data'] as $pago) { + $pagos[] = [ + 'tipo' => 'pyto_peru', + 'codigo' => $pago['autorizationCode'] ?? null, + 'monto' => $pago['total'] ?? null, + 'fecha_pago' => $pago['confirmedDate'] ?? null, + 'estado' => true, + 'raw' => $pago // devuelve toda la info original + ]; + } + } + } + + // =============================== + // 2️⃣ PAGOS CAJA + // =============================== + $urlCaja = "https://inscripciones.admision.unap.edu.pe/api/get-pagos-caja-dni/{$dni}"; + $responseCaja = Http::get($urlCaja); + + if ($responseCaja->successful()) { + $dataCaja = $responseCaja->json(); + + if (!empty($dataCaja)) { + foreach ($dataCaja as $pago) { + $pagos[] = [ + 'tipo' => 'caja', + 'codigo' => $pago['paymentTitle'] ?? null, + 'monto' => $pago['paymentAmount'] ?? null, + 'fecha_pago' => $pago['paymentDatetime'] ?? null, + 'estado' => true, + 'raw' => $pago + ]; + } + } + } + + // =============================== + // 3️⃣ BANCO NACIÓN + // =============================== + $urlBanco = 'https://inscripciones.admision.unap.edu.pe/api/get-pagos-banco-dni'; + + $responseBanco = Http::post($urlBanco, [ + 'dni' => $dni, + ]); + + if ($responseBanco->successful()) { + $dataBanco = $responseBanco->json(); + + if (!empty($dataBanco['datos'])) { + foreach ($dataBanco['datos'] as $pago) { + $pagos[] = [ + 'tipo' => 'banco_nacion', + 'codigo' => $pago['secuencia'] ?? null, + 'monto' => $pago['imp_pag'] ?? null, + 'fecha_pago' => $pago['fch_pag'] ?? null, + 'estado' => true, + 'raw' => $pago + ]; + } + } + } + + return response()->json([ + 'success' => true, + 'postulante' => [ + 'id' => $postulante->id, + 'name' => $postulante->name, + 'dni' => $dni, + 'email' => $postulante->email, + ], + 'total_pagos' => count($pagos), + 'pagos' => $pagos + ]); + } + +public function misProcesos(Request $request) +{ + $dni = $request->user()->dni; + + $procesos = ResultadoAdmision::select( + 'procesos_admision.id', + 'procesos_admision.nombre', + 'resultados_admision.puntaje', + 'resultados_admision.apto' + ) + ->join('procesos_admision', 'procesos_admision.id', '=', 'resultados_admision.idproceso') + ->where('resultados_admision.dni', $dni) + ->distinct() + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $procesos + ]); +} + +} diff --git a/back/app/Models/Area.php b/back/app/Models/Area.php index 45de851..fb8f7df 100644 --- a/back/app/Models/Area.php +++ b/back/app/Models/Area.php @@ -95,7 +95,12 @@ class Area extends Model public function procesos() { - return $this->belongsToMany(Proceso::class, 'area_proceso')->withTimestamps(); + return $this->belongsToMany(Proceso::class, 'area_proceso') + ->withPivot('id') + ->withTimestamps(); } + + + } diff --git a/back/app/Models/AreaAdmision.php b/back/app/Models/AreaAdmision.php new file mode 100644 index 0000000..9c9e433 --- /dev/null +++ b/back/app/Models/AreaAdmision.php @@ -0,0 +1,32 @@ + 'boolean' + ]; + + /* + |-------------------------------------------------------------------------- + | RELACIONES + |-------------------------------------------------------------------------- + */ + + // Un área tiene muchos resultados + public function resultados() + { + return $this->hasMany(ResultadoAdmision::class, 'idearea'); + } +} diff --git a/back/app/Models/Examen.php b/back/app/Models/Examen.php new file mode 100644 index 0000000..7ab594f --- /dev/null +++ b/back/app/Models/Examen.php @@ -0,0 +1,69 @@ +belongsTo(Postulante::class, 'postulante_id'); + } + + + // public function area() + // { + // return $this->belongsTo(Area::class, 'area_id'); + // } + + public function areaProceso() + { + return $this->belongsTo(AreaProceso::class, 'area_proceso_id'); + } + + public function pago() + { + return $this->belongsTo(Pago::class, 'pago_id'); + } + + // Accesos rápidos opcionales + public function area() + { + 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... + ); + } + + public function proceso() + { + return $this->areaProceso->proceso; + } + + public function preguntasAsignadas() + { + return $this->hasMany(PreguntaAsignada::class, 'examen_id'); + } + +} diff --git a/back/app/Models/Pago.php b/back/app/Models/Pago.php new file mode 100644 index 0000000..f01ca43 --- /dev/null +++ b/back/app/Models/Pago.php @@ -0,0 +1,23 @@ + 'datetime', + ]; + + /** + * Mutator para encriptar la contraseña automáticamente + */ + public function setPasswordAttribute($value) + { + $this->attributes['password'] = bcrypt($value); + } +} diff --git a/back/app/Models/PreguntaAsignada.php b/back/app/Models/PreguntaAsignada.php new file mode 100644 index 0000000..1124262 --- /dev/null +++ b/back/app/Models/PreguntaAsignada.php @@ -0,0 +1,39 @@ + 'boolean', + 'puntaje' => 'decimal:2' + ]; + + protected $dates = ['respondida_at']; + + public function examen(): BelongsTo + { + return $this->belongsTo(Examen::class); + } + + public function pregunta(): BelongsTo + { + return $this->belongsTo(Pregunta::class); + } +} \ No newline at end of file diff --git a/back/app/Models/Proceso.php b/back/app/Models/Proceso.php index e03fa1f..70db393 100644 --- a/back/app/Models/Proceso.php +++ b/back/app/Models/Proceso.php @@ -34,6 +34,7 @@ class Proceso extends Model 'fecha_inicio', 'fecha_fin', 'tiempo_por_pregunta', + 'cantidad_pregunta', ]; protected $casts = [ @@ -113,11 +114,15 @@ class Proceso extends Model }); } - public function areas() - { - return $this->belongsToMany( - Area::class, - 'area_proceso' - )->withTimestamps(); - } +public function areas() +{ + return $this->belongsToMany( + Area::class, + 'area_proceso' // nombre de la tabla pivot + ) + ->withPivot('id') // columnas extra de la tabla pivot que quieres acceder + ->withTimestamps(); // para manejar created_at y updated_at automáticamente +} + + } diff --git a/back/app/Models/ProcesoAdmision.php b/back/app/Models/ProcesoAdmision.php new file mode 100644 index 0000000..39206ba --- /dev/null +++ b/back/app/Models/ProcesoAdmision.php @@ -0,0 +1,35 @@ + 'datetime', + 'fecha_fin' => 'datetime', + 'estado' => 'boolean' + ]; + + /* + |-------------------------------------------------------------------------- + | RELACIONES + |-------------------------------------------------------------------------- + */ + + // Un proceso tiene muchos resultados + public function resultados() + { + return $this->hasMany(ResultadoAdmision::class, 'idproceso'); + } +} diff --git a/back/app/Models/ReglaAreaProceso.php b/back/app/Models/ReglaAreaProceso.php new file mode 100644 index 0000000..f383030 --- /dev/null +++ b/back/app/Models/ReglaAreaProceso.php @@ -0,0 +1,32 @@ +belongsTo(AreaProceso::class); + } + + public function curso() + { + return $this->belongsTo(Curso::class); + } +} diff --git a/back/app/Models/ResultadoAdmision.php b/back/app/Models/ResultadoAdmision.php new file mode 100644 index 0000000..9ec8037 --- /dev/null +++ b/back/app/Models/ResultadoAdmision.php @@ -0,0 +1,62 @@ + 'decimal:2', + 'vocacional' => 'decimal:2', + 'desprograma' => 'boolean', + 'calificar' => 'boolean', + 'respuestas' => 'array' + ]; + + /* + |-------------------------------------------------------------------------- + | RELACIONES + |-------------------------------------------------------------------------- + */ + + public function proceso() + { + return $this->belongsTo(ProcesoAdmision::class, 'idproceso'); + } + + public function area() + { + return $this->belongsTo(AreaAdmision::class, 'idearea'); + } + + public function detalleCursos() + { + return $this->hasOne(ResultadoAdmisionCarga::class, 'dni', 'dni') + ->whereColumn('idproceso', 'resultados_admision.idproceso') + ->whereColumn('idearea', 'resultados_admision.idearea'); + } +} diff --git a/back/app/Models/ResultadoAdmisionCarga.php b/back/app/Models/ResultadoAdmisionCarga.php new file mode 100644 index 0000000..ded238b --- /dev/null +++ b/back/app/Models/ResultadoAdmisionCarga.php @@ -0,0 +1,148 @@ + '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() + { + return $this->belongsTo(ProcesoAdmision::class, 'idproceso'); + } + + public function area() + { + return $this->belongsTo(AreaAdmision::class, 'idearea'); + } +} diff --git a/back/app/Services/ExamenService.php b/back/app/Services/ExamenService.php new file mode 100644 index 0000000..daddafd --- /dev/null +++ b/back/app/Services/ExamenService.php @@ -0,0 +1,164 @@ +preguntasAsignadas()->exists()) { + return [ + 'success' => false, + 'message' => 'El examen ya tiene preguntas' + ]; + } + + $reglas = ReglaAreaProceso::where('area_proceso_id', $examen->area_proceso_id) + ->orderBy('orden') + ->get(); + + if ($reglas->isEmpty()) { + return [ + 'success' => false, + 'message' => 'No hay reglas configuradas' + ]; + } + + DB::beginTransaction(); + + try { + $orden = 1; + + foreach ($reglas as $regla) { + $preguntas = Pregunta::where('curso_id', $regla->curso_id) + ->where('activo', 1) + ->when($regla->nivel_dificultad, fn ($q) => + $q->where('nivel_dificultad', $regla->nivel_dificultad) + ) + ->inRandomOrder() + ->limit($regla->cantidad_preguntas) + ->get(); + + if ($preguntas->count() < $regla->cantidad_preguntas) { + throw new \Exception("Preguntas insuficientes para curso {$regla->curso_id}"); + } + + foreach ($preguntas as $pregunta) { + PreguntaAsignada::create([ + 'examen_id' => $examen->id, + 'pregunta_id' => $pregunta->id, + 'orden' => $orden++, + 'puntaje_base' => $regla->ponderacion, + 'estado' => 'pendiente', + ]); + } + } + + DB::commit(); + + return ['success' => true]; + + } catch (\Throwable $e) { + DB::rollBack(); + return ['success' => false, 'message' => $e->getMessage()]; + } + } + + +public function obtenerPreguntasExamen(Examen $examen): array +{ + // Traemos preguntas con curso + $preguntas = $examen->preguntasAsignadas() + ->with('pregunta.curso') + ->get() + ->sortBy('orden'); + + // Traemos datos del área-proceso directamente desde la DB + $areaProceso = \DB::table('area_proceso') + ->join('procesos', 'area_proceso.proceso_id', '=', 'procesos.id') + ->join('areas', 'area_proceso.area_id', '=', 'areas.id') + ->where('area_proceso.id', $examen->area_proceso_id) + ->select( + 'procesos.nombre as proceso_nombre', + 'procesos.duracion as proceso_duracion', + 'procesos.intentos_maximos as proceso_intentos_maximos', + 'areas.nombre as area_nombre' + ) + ->first(); + + return $preguntas->map(fn($pa) => [ + 'id' => $pa->id, + 'orden' => $pa->orden, + 'enunciado' => $pa->pregunta->enunciado, + 'extra' => $pa->pregunta->enunciado_adicional, + 'opciones' => $this->mezclarOpciones($pa->pregunta->opciones), + 'imagenes' => $pa->pregunta->imagenes, + 'estado' => $pa->estado, + 'respuesta' => $pa->pregunta->respuesta_correcta, + 'curso' => $pa->pregunta->curso->nombre ?? null, + 'proceso' => $areaProceso->proceso_nombre ?? null, + 'duracion' => $areaProceso->proceso_duracion ?? null, + 'intentos_maximos'=> $areaProceso->proceso_intentos_maximos ?? null, + 'area' => $areaProceso->area_nombre ?? null + ])->values()->toArray(); +} + + + + + public function guardarRespuesta(PreguntaAsignada $pa, string $respuesta): array + { + if ($pa->estado === 'respondida') { + return ['success' => false, 'message' => 'Ya respondida']; + } + + $esCorrecta = $respuesta === $pa->pregunta->respuesta_correcta; + + $pa->update([ + 'respuesta_usuario' => $respuesta, + 'es_correcta' => $esCorrecta, + 'puntaje_obtenido' => $esCorrecta ? $pa->puntaje_base : 0, + 'estado' => 'respondida', + 'respondida_at' => now() + ]); + + return [ + 'success' => true, + 'correcta' => $esCorrecta, + 'puntaje' => $pa->puntaje_obtenido + ]; + } + + /** + * Finalizar examen + */ + public function finalizarExamen(Examen $examen): void + { + $examen->update([ + 'estado' => 'finalizado', + 'hora_fin' => now() + ]); + } + + private function mezclarOpciones(?array $opciones): array + { + if (!$opciones) return []; + + $keys = array_keys($opciones); + shuffle($keys); + + return array_map(fn ($k) => [ + 'key' => $k, + 'texto' => $opciones[$k] + ], $keys); + } +} diff --git a/back/config/auth.php b/back/config/auth.php index 7d1eb0d..50416eb 100644 --- a/back/config/auth.php +++ b/back/config/auth.php @@ -40,6 +40,11 @@ return [ 'driver' => 'session', 'provider' => 'users', ], + + 'postulante' => [ + 'driver' => 'sanctum', + 'provider' => 'postulantes', + ], ], /* @@ -69,6 +74,11 @@ return [ // 'driver' => 'database', // 'table' => 'users', // ], + + 'postulantes' => [ + 'driver' => 'eloquent', + 'model' => App\Models\Postulante::class, + ], ], /* diff --git a/back/routes/api.php b/back/routes/api.php index 22263a4..67fd8fa 100644 --- a/back/routes/api.php +++ b/back/routes/api.php @@ -11,6 +11,9 @@ use App\Http\Controllers\Administracion\AreaController; use App\Http\Controllers\Administracion\CursoController; use App\Http\Controllers\Administracion\PreguntaController; use App\Http\Controllers\Administracion\ProcesoController; +use App\Http\Controllers\PostulanteAuthController; +use App\Http\Controllers\ExamenController; +use App\Http\Controllers\Administracion\ReglaAreaProcesoController; Route::get('/user', function (Request $request) { @@ -81,3 +84,74 @@ Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () { }); + +Route::prefix('postulante')->group(function () { + + // Registro + Route::post('/register', [PostulanteAuthController::class, 'register']); + + // Login + Route::post('/login', [PostulanteAuthController::class, 'login']); + + // Rutas protegidas por token + Route::middleware('auth:sanctum')->group(function () { + Route::post('/logout', [PostulanteAuthController::class, 'logout']); + Route::get('/me', [PostulanteAuthController::class, 'me']); + Route::get('/pagos', [PostulanteAuthController::class, 'obtenerPagosPostulante']); + Route::get('/postulante/mis-procesos',[PostulanteAuthController::class, 'misProcesos']); + + }); + + +}); + + +// Route::middleware('auth:sanctum')->group(function () { +// Route::get('/procesos', [ExamenController::class, 'procesoexamen']); +// Route::get('/areas', [ExamenController::class, 'areas']); +// Route::post('/examen', [ExamenController::class, 'store']); +// Route::get('/examen/actual', [ExamenController::class, 'miExamenActual']); +// }); + + +Route::middleware(['auth:sanctum'])->prefix('area-proceso')->group(function () { + Route::get('areasprocesos', [ReglaAreaProcesoController::class, 'areasProcesos']); + Route::prefix('{areaProcesoId}/reglas')->group(function () { + Route::get('/', [ReglaAreaProcesoController::class, 'index']); // Listar reglas + Route::post('/', [ReglaAreaProcesoController::class, 'store']); // Crear/actualizar regla individual + Route::post('/multiple', [ReglaAreaProcesoController::class, 'storeMultiple']); // Guardar múltiples reglas + }); +}); + +Route::middleware(['auth:sanctum'])->prefix('reglas')->group(function () { + Route::put('/{reglaId}', [ReglaAreaProcesoController::class, 'update']); // Editar regla + Route::delete('/{reglaId}', [ReglaAreaProcesoController::class, 'destroy']); // Eliminar regla +}); + + + +// Examen - Flujo separado +Route::middleware(['auth:postulante'])->group(function () { + // Configuración + Route::get('/examen/procesos', [ExamenController::class, 'procesoexamen']); + Route::get('/examen/areas', [ExamenController::class, 'areas']); + Route::get('/examen/actual', [ExamenController::class, 'miExamenActual']); + + // Crear examen (sin preguntas) + Route::post('/examen/crear', [ExamenController::class, 'crearExamen']); + + // Generar preguntas + Route::post('/examen/{examen}/generar-preguntas', [ExamenController::class, 'generarPreguntas']); + + // Obtener preguntas + Route::get('/examen/{examen}/preguntas', [ExamenController::class, 'obtenerPreguntas']); + + // Iniciar examen (marcar hora inicio) + Route::post('/examen/iniciar', [ExamenController::class, 'iniciarExamen']); + + // Responder preguntas + Route::post('/examen/pregunta/{pregunta}/responder', [ExamenController::class, 'responderPregunta']); + + // Finalizar examen + Route::post('/examen/{examen}/finalizar', [ExamenController::class, 'finalizarExamen']); +}); \ No newline at end of file diff --git a/front/package-lock.json b/front/package-lock.json index dd9f2ca..7158e86 100644 --- a/front/package-lock.json +++ b/front/package-lock.json @@ -1188,6 +1188,7 @@ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -1197,6 +1198,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "license": "MIT", + "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -1342,6 +1344,7 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -1351,7 +1354,6 @@ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", "license": "MIT", - "peer": true, "dependencies": { "@kurkle/color": "^0.3.0" }, @@ -1364,6 +1366,7 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", "license": "ISC", + "peer": true, "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", @@ -1384,6 +1387,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", + "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -1395,7 +1399,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/combined-stream": { "version": "1.0.8", @@ -1467,6 +1472,7 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -1547,7 +1553,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dom-align": { "version": "1.12.4", @@ -1579,7 +1586,8 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/entities": { "version": "7.0.1", @@ -1727,6 +1735,7 @@ "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "license": "MIT", + "peer": true, "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" @@ -1809,6 +1818,7 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "license": "ISC", + "peer": true, "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -1956,6 +1966,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2013,7 +2024,6 @@ "https://opencollective.com/katex", "https://github.com/sponsors/katex" ], - "license": "MIT", "dependencies": { "commander": "^8.3.0" }, @@ -2035,6 +2045,7 @@ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "license": "MIT", + "peer": true, "dependencies": { "p-locate": "^4.1.0" }, @@ -2251,6 +2262,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", "license": "MIT", + "peer": true, "dependencies": { "p-try": "^2.0.0" }, @@ -2266,6 +2278,7 @@ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "license": "MIT", + "peer": true, "dependencies": { "p-limit": "^2.2.0" }, @@ -2278,6 +2291,7 @@ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -2297,6 +2311,7 @@ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2319,7 +2334,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2353,6 +2367,7 @@ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.13.0" } @@ -2503,6 +2518,7 @@ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -2511,7 +2527,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", @@ -2583,7 +2600,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/set-function-length": { "version": "1.2.2", @@ -2646,6 +2664,7 @@ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "license": "MIT", + "peer": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -2660,6 +2679,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -2729,7 +2749,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2804,7 +2823,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", @@ -2896,13 +2914,15 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/wrap-ansi": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "license": "MIT", + "peer": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -2916,13 +2936,15 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", "license": "MIT", + "peer": true, "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", @@ -2945,6 +2967,7 @@ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", "license": "ISC", + "peer": true, "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" diff --git a/front/public/logotiny.png b/front/public/logotiny.png new file mode 100644 index 0000000000000000000000000000000000000000..a3463b7c4aff6554f920d43c2b8588551e351037 GIT binary patch literal 48191 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 DOiefi literal 0 HcmV?d00001 diff --git a/front/src/axiosPostulante.js b/front/src/axiosPostulante.js new file mode 100644 index 0000000..cbc069b --- /dev/null +++ b/front/src/axiosPostulante.js @@ -0,0 +1,32 @@ +import axios from 'axios' +import router from './router' +import { useAuthStore } from './store/postulanteStore' + +const apiPostulante = axios.create({ + baseURL: import.meta.env.VITE_API_URL, + headers: { 'Content-Type': 'application/json', Accept: 'application/json' } +}) + +// Interceptor para agregar token del postulante +apiPostulante.interceptors.request.use(config => { + const postulanteStore = useAuthStore() + if (postulanteStore.token) { + config.headers.Authorization = `Bearer ${postulanteStore.token}` + } + return config +}) + +// Manejo de errores (401) +apiPostulante.interceptors.response.use( + response => response, + error => { + const postulanteStore = useAuthStore() + if (error.response?.status === 401) { + postulanteStore.logout() + router.push('/login-postulante') + } + return Promise.reject(error) + } +) + +export default apiPostulante diff --git a/front/src/components/Footer.vue b/front/src/components/Footer.vue new file mode 100644 index 0000000..b78bcb6 --- /dev/null +++ b/front/src/components/Footer.vue @@ -0,0 +1,167 @@ + + + + + diff --git a/front/src/components/HelloWorld.vue b/front/src/components/HelloWorld.vue deleted file mode 100644 index a5fc821..0000000 --- a/front/src/components/HelloWorld.vue +++ /dev/null @@ -1,1414 +0,0 @@ - - - - - \ No newline at end of file diff --git a/front/src/components/Postulante/VincularAcademia.vue b/front/src/components/Postulante/VincularAcademia.vue deleted file mode 100644 index 3fbacd4..0000000 --- a/front/src/components/Postulante/VincularAcademia.vue +++ /dev/null @@ -1,615 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/front/src/components/SuperAdmin/AcademiasList.vue b/front/src/components/SuperAdmin/AcademiasList.vue deleted file mode 100644 index 87608f1..0000000 --- a/front/src/components/SuperAdmin/AcademiasList.vue +++ /dev/null @@ -1,307 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/front/src/components/WebPage.vue b/front/src/components/WebPage.vue new file mode 100644 index 0000000..1abbfeb --- /dev/null +++ b/front/src/components/WebPage.vue @@ -0,0 +1,1140 @@ + + + + + + \ No newline at end of file diff --git a/front/src/components/nabvar.vue b/front/src/components/nabvar.vue new file mode 100644 index 0000000..fa95146 --- /dev/null +++ b/front/src/components/nabvar.vue @@ -0,0 +1,658 @@ + + + + + \ No newline at end of file diff --git a/front/src/router/index.js b/front/src/router/index.js index c772e72..7831dc8 100644 --- a/front/src/router/index.js +++ b/front/src/router/index.js @@ -1,24 +1,63 @@ +// router/index.js import { createRouter, createWebHistory } from 'vue-router' import Login from '../views/Login.vue' -import Hello from '../components/HelloWorld.vue' +import Hello from '../components/WebPage.vue' import { useUserStore } from '../store/user' +import { useAuthStore as usePostulanteStore } from '../store/postulanteStore' const routes = [ - - { - path: '/', - component: Hello - }, + // Home + { path: '/', component: Hello }, + + // Login usuarios/admins + { path: '/login', component: Login, meta: { guest: true } }, + + // Login postulante { - path: '/login', - component: Login, - meta: { guest: true } + path: '/login-postulante', + name: 'login-postulante', + component: () => import('../views/postulante/LoginView.vue'), + meta: { guest: true }, }, + + // Portal postulante { - path: '/unauthorized', - name: 'Unauthorized', - component: () => import('../views/403.vue') + path: '/portal-postulante', + name: 'portal-postulante', + component: () => import('../views/postulante/PortalView.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'DashboardPostulante', + component: () => import('../views/postulante/Dashboard.vue'), + meta: { requiresAuth: true} + }, + { + path: '/portal-postulante/examen/:examenId', + name: 'PanelExamen', + component: () => import('../views/postulante/PreguntasExamen.vue'), + meta: { requiresAuth: true } + }, + { + path: '/portal-postulante/resultados/:examenId', + name: 'PanelResultados', + component: () => import('../views/postulante/Resultados.vue'), + meta: { requiresAuth: true } + }, + + { + path: '/portal-postulante/pagos', + name: 'PanelPagos', + component: () => import('../views/postulante/Pagos.vue'), + meta: { requiresAuth: true } + }, + ] + + }, + + // Usuario normal { path: '/usuario/dashboard', name: 'dashboard', @@ -26,6 +65,7 @@ const routes = [ meta: { requiresAuth: true, role: 'usuario' } }, + // Admin { path: '/admin/dashboard', component: () => import('../views/administrador/layout/Layout.vue'), @@ -37,50 +77,50 @@ const routes = [ component: () => import('../views/administrador/Dashboard.vue'), meta: { requiresAuth: true, role: 'administrador' } }, - { path: '/admin/dashboard/areas', name: 'Areas', component: () => import('../views/administrador/areas/AreasList.vue'), meta: { requiresAuth: true, role: 'administrador' } }, - { path: '/admin/dashboard/cursos', name: 'Cursos', component: () => import('../views/administrador/cursos/CursosList.vue'), meta: { requiresAuth: true, role: 'administrador' } }, - { path: '/admin/dashboard/cursos/:id/preguntas', name: 'CursoPreguntas', component: () => import('../views/administrador/cursos/PreguntasCursoView.vue'), meta: { requiresAuth: true, role: 'administrador' } }, - { path: '/admin/dashboard/procesos', name: 'Procesos', component: () => import('../views/administrador/Procesos/ProcesosList.vue'), meta: { requiresAuth: true, role: 'administrador' } + }, + { + path: '/admin/dashboard/reglas', + name: 'Reglas', + component: () => import('../views/administrador/Procesos/ReglasList.vue'), + meta: { requiresAuth: true, role: 'administrador' } } - ] }, - + // Superadmin { path: '/superadmin/dashboard', name: 'superadmin-dashboard', component: () => import('../views/superadmin/Dashboard.vue'), meta: { requiresAuth: true, role: 'superadmin' } }, - { - path: '/403', - name: 'forbidden', - component: () => import('../views/403.vue') - } + + // Errores + { path: '/unauthorized', name: 'Unauthorized', component: () => import('../views/403.vue') }, + { path: '/403', name: 'forbidden', component: () => import('../views/403.vue') } ] const router = createRouter({ @@ -90,19 +130,30 @@ const router = createRouter({ router.beforeEach((to, from, next) => { const userStore = useUserStore() + const postulanteStore = usePostulanteStore() - // 🚫 No autenticado - if (to.meta.requiresAuth && !userStore.isAuth) { + // --- Rutas protegidas para usuarios/admin --- + if (to.meta.requiresAuth && !to.path.startsWith('/portal-postulante') && !userStore.isAuth) { return next('/login') } - // 🚫 Autenticado intentando ir a login - if (to.meta.guest && userStore.isAuth) { + // --- 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 } - // 🚫 Rol requerido incorrecto + // --- 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') } @@ -110,6 +161,4 @@ router.beforeEach((to, from, next) => { next() }) - - export default router diff --git a/front/src/store/areacursoStore.js b/front/src/store/areacursoStore.js deleted file mode 100644 index 444b049..0000000 --- a/front/src/store/areacursoStore.js +++ /dev/null @@ -1,74 +0,0 @@ -// stores/areasStore.js -import { defineStore } from 'pinia'; -import axios from 'axios'; - -export const useAreasStore = defineStore('areas', { - state: () => ({ - cursosDisponibles: [], // todos los cursos - cursosVinculados: [], // cursos vinculados a la área - loading: false, - error: null, - }), - - actions: { - // Obtener cursos de un área - async fetchCursosPorArea(areaId) { - this.loading = true; - this.error = null; - try { - const response = await axios.get(`/areas/${areaId}/cursos-disponibles`); - if (response.data.success) { - this.cursosDisponibles = response.data.data.todos_los_cursos; - this.cursosVinculados = response.data.data.cursos_vinculados; - } else { - this.error = 'Error cargando cursos'; - } - } catch (err) { - this.error = err.response?.data?.message || err.message; - } finally { - this.loading = false; - } - }, - - // Vincular cursos a un área - async vincularCursos(areaId, cursosIds) { - this.loading = true; - this.error = null; - try { - const response = await axios.post(`/areas/${areaId}/vincular-cursos`, { - cursos: cursosIds - }); - if (response.data.success) { - this.cursosVinculados = cursosIds; - } else { - this.error = 'Error vinculando cursos'; - } - } catch (err) { - this.error = err.response?.data?.message || err.message; - } finally { - this.loading = false; - } - }, - - // Desvincular un curso de un área - async desvincularCurso(areaId, cursoId) { - this.loading = true; - this.error = null; - try { - const response = await axios.post(`/areas/${areaId}/desvincular-curso`, { - curso_id: cursoId - }); - if (response.data.success) { - // Actualizar lista local - this.cursosVinculados = this.cursosVinculados.filter(id => id !== cursoId); - } else { - this.error = 'Error desvinculando curso'; - } - } catch (err) { - this.error = err.response?.data?.message || err.message; - } finally { - this.loading = false; - } - } - } -}); diff --git a/front/src/store/examen.store.js b/front/src/store/examen.store.js new file mode 100644 index 0000000..69f42d6 --- /dev/null +++ b/front/src/store/examen.store.js @@ -0,0 +1,139 @@ +import { defineStore } from 'pinia' +import api from '../axiosPostulante' + + +export const useExamenStore = defineStore('examenStore', { + state: () => ({ + procesos: [], + areas: [], + examenActual: null, + preguntas: [], + cargando: false, + error: null, + }), + actions: { + // 1. Obtener procesos disponibles para el postulante + async fetchProcesos() { + try { + this.cargando = true + const { data } = await api.get('/examen/procesos') + this.procesos = data + } catch (e) { + this.error = e.response?.data?.message || e.message + } finally { + this.cargando = false + } + }, + + // 2. Obtener áreas por proceso + async fetchAreas(proceso_id) { + try { + this.cargando = true + const { data } = await api.get('/examen/areas', { + params: { proceso_id } + }) + this.areas = data + } catch (e) { + this.error = e.response?.data?.message || e.message + } finally { + this.cargando = false + } + }, + + // 3. Crear examen (sin preguntas) + async crearExamen(area_proceso_id, pago = null) { + try { + this.cargando = true + const payload = { area_proceso_id, ...pago } + const { data } = await api.post('/examen/crear', payload) + this.examenActual = { id: data.examen_id } + return data + } catch (e) { + this.error = e.response?.data?.message || e.message + return { success: false, message: this.error } + } finally { + this.cargando = false + } + }, + + // 4. Obtener examen actual + async fetchExamenActual() { + try { + this.cargando = true + const { data } = await api.get('/examen/actual') + this.examenActual = data.examen + } catch (e) { + this.error = e.response?.data?.message || e.message + } finally { + this.cargando = false + } + }, + + // 5. Generar preguntas para un examen + async generarPreguntas(examenId) { + try { + this.cargando = true + const { data } = await api.post(`/examen/${examenId}/generar-preguntas`) + return data + } catch (e) { + this.error = e.response?.data?.message || e.message + return { success: false, message: this.error } + } finally { + this.cargando = false + } + }, + + // 6. Iniciar examen + async iniciarExamen(examenId) { + try { + this.cargando = true + const { data } = await api.post('/examen/iniciar', { examen_id: examenId }) + this.examenActual = data.examen + this.preguntas = data.preguntas + return data + } catch (e) { + this.error = e.response?.data?.message || e.message + return { success: false, message: this.error } + } finally { + this.cargando = false + } + }, + + // 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 + } catch (e) { + this.error = e.response?.data?.message || e.message + return { success: false, message: this.error } + } + }, + + // 8. Finalizar examen + async finalizarExamen(examenId) { + try { + const { data } = await api.post(`/examen/${examenId}/finalizar`) + this.examenActual = null + this.preguntas = [] + return data + } catch (e) { + this.error = e.response?.data?.message || e.message + return { success: false, message: this.error } + } + }, + + // Limpiar estado + resetStore() { + this.procesos = [] + this.areas = [] + this.examenActual = null + this.preguntas = [] + this.cargando = false + this.error = null + } + } +}) diff --git a/front/src/store/postulanteStore.js b/front/src/store/postulanteStore.js new file mode 100644 index 0000000..3a0a8ce --- /dev/null +++ b/front/src/store/postulanteStore.js @@ -0,0 +1,141 @@ +// 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 + error.value = null + + const response = await api.post('/postulante/register', { + name, + email, + password, + password_confirmation, + dni + }) + + if (response.data.success) { + token.value = response.data.token + postulante.value = response.data.postulante + + localStorage.setItem('postulante_token', token.value) + localStorage.setItem('postulante_data', JSON.stringify(postulante.value)) + + return { success: true } + } + + return { success: false, error: response.data.message || 'Error en registro' } + } catch (err) { + error.value = err.response?.data?.message || 'Error en registro' + return { success: false, error: error.value } + } finally { + loading.value = false + } + } + + // Login + const login = async ({ email, password, device_id = null }) => { + try { + loading.value = true + error.value = null + + const response = await api.post('/postulante/login', { email, password, device_id }) + + if (response.data.success) { + token.value = response.data.token + postulante.value = response.data.postulante + + localStorage.setItem('postulante_token', token.value) + localStorage.setItem('postulante_data', JSON.stringify(postulante.value)) + + return { success: true } + } + + return { success: false, error: response.data.message || 'Credenciales inválidas' } + } catch (err) { + error.value = err.response?.data?.message || 'Error en login' + return { success: false, error: error.value } + } finally { + loading.value = false + } + } + + // Logout + const logout = async () => { + try { + if (token.value) { + await api.post('/postulante/logout', {}, { + headers: { Authorization: `Bearer ${token.value}` } + }) + } + } catch (err) { + console.error('Error en logout:', err) + } finally { + token.value = null + postulante.value = null + localStorage.removeItem('postulante_token') + localStorage.removeItem('postulante_data') + } + } + + // Check sesión + const checkAuth = async () => { + if (!token.value) return false + + try { + const response = await api.get('/postulante/me', { + headers: { Authorization: `Bearer ${token.value}` } + }) + + if (response.data.success) { + postulante.value = response.data.postulante + return true + } + } catch (err) { + console.error('Error verificando autenticación:', err) + await logout() + } + + return false + } + + return { + // Estado + token, + postulante, + loading, + error, + + // Getters + isAuthenticated, + userDni, + userEmail, + userName, + userId, + + // Actions + register, + login, + logout, + checkAuth + } +}) diff --git a/front/src/store/reglaAreaProceso.store.js b/front/src/store/reglaAreaProceso.store.js new file mode 100644 index 0000000..54e0ca8 --- /dev/null +++ b/front/src/store/reglaAreaProceso.store.js @@ -0,0 +1,257 @@ +import { defineStore } from 'pinia' +import api from '../axios' + +export const useReglaAreaProcesoStore = defineStore('reglaAreaProceso', { + state: () => ({ + areaProcesoId: null, + proceso: null, + reglas: [], + areaProcesos: [], + cargando: false, + guardando: false, + error: null, + totalPreguntasAsignadas: 0, + preguntasDisponibles: 0, + }), + + 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() + }, +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) { + console.error(err) + this.error = err.response?.data?.message || 'Error al cargar áreas-proceso' + } finally { + this.cargando = false + } +}, + + + /** + * Cargar reglas de un area_proceso + */ + async cargarReglas() { + if (!this.areaProcesoId) return + this.cargando = true + this.error = null + try { + const { data } = await api.get(`/area-proceso/${this.areaProcesoId}/reglas`) + this.proceso = data.proceso + this.reglas = data.cursos + this.totalPreguntasAsignadas = data.total_preguntas_asignadas || 0 + this.preguntasDisponibles = this.proceso.cantidad_total_preguntas - this.totalPreguntasAsignadas + } catch (err) { + console.error('Error al cargar reglas:', err) + this.error = err.response?.data?.message || 'Error al cargar reglas' + } finally { + this.cargando = false + } + }, + + /** + * Crear o actualizar una regla + */ + async guardarRegla(regla) { + if (!this.areaProcesoId) return + this.guardando = true + this.error = null + 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 + + return { success: true, data } + } catch (err) { + console.error('Error al guardar regla:', err) + this.error = err.response?.data?.error || err.response?.data?.message || 'Error al guardar regla' + return { + success: false, + error: this.error + } + } finally { + this.guardando = false + } + }, + + /** + * Guardar múltiples reglas a la vez + */ + async guardarReglasMultiple(reglas) { + if (!this.areaProcesoId) return + this.guardando = true + this.error = null + try { + const { data } = await api.post(`/area-proceso/${this.areaProcesoId}/reglas/multiple`, { + reglas: reglas + }) + + // Recargar las reglas desde el servidor para asegurar consistencia + await this.cargarReglas() + + return { success: true, data } + } catch (err) { + console.error('Error al guardar reglas:', err) + this.error = err.response?.data?.error || err.response?.data?.message || 'Error al guardar reglas' + return { + success: false, + error: this.error + } + } finally { + this.guardando = false + } + }, + + /** + * 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 } + } catch (err) { + console.error('Error al editar regla:', err) + this.error = err.response?.data?.error || err.response?.data?.message || 'Error al editar regla' + return { + success: false, + error: this.error + } + } finally { + this.guardando = false + } + }, + + /** + * 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 + } + + return { success: true, data } + } catch (err) { + console.error('Error al eliminar regla:', err) + this.error = err.response?.data?.message || 'Error al eliminar regla' + return { + success: false, + error: this.error + } + } finally { + this.guardando = false + } + }, + + /** + * 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) { + this.reglas[reglaIndex].orden = index + 1 + } + }) + + // 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 }) + } + } + } + }, + + /** + * 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 + this.reglas = [] + this.totalPreguntasAsignadas = 0 + this.preguntasDisponibles = 0 + this.error = null + }, + } +}) \ No newline at end of file diff --git a/front/src/views/administrador/Dashboard.vue b/front/src/views/administrador/Dashboard.vue index 390b3e6..1b9552a 100644 --- a/front/src/views/administrador/Dashboard.vue +++ b/front/src/views/administrador/Dashboard.vue @@ -206,7 +206,7 @@ +
+ +
+
+

Reglas de Evaluación

+

Configuración de reglas por área-proceso

+
+ + + Volver + +
+ + +
+
+
+

Seleccionar Área Proceso

+

Selecciona un área-proceso para configurar sus reglas

+
+
+ +
+ + + Todos los procesos + + {{ proceso.nombre }} + + + + Limpiar + +
+ + + + + +
+
+
+ + +
+ +
+
+

{{ store.proceso?.nombre }} / {{ nombreAreaActual }}

+
+
Total Preguntas: {{ store.totalPreguntasProceso }}
+
Asignadas: {{ store.totalPreguntasAsignadas }}
+
Disponibles: {{ store.preguntasDisponibles }}
+
+
+ + +
+
+ Progreso de asignación + {{ porcentajeAsignado }}% +
+ +
+ +
+
+ + +
+ + Distribuir Automático + + + Guardar Todo + + + Deshacer Cambios + +
+
+ + +
+ + + +
+ + +
+ +
+
Cursos configurados: {{ cursosConfigurados }} / {{ store.reglas.length }}
+
Total preguntas asignadas: {{ store.totalPreguntasAsignadas }}
+
Preguntas disponibles: {{ store.preguntasDisponibles }}
+
Porcentaje completado: {{ porcentajeAsignado }}%
+
+
+
+
+ + + +

{{ confirmMessage }}

+
+
+ + + + + + \ No newline at end of file diff --git a/front/src/views/administrador/areas/AreasList.vue b/front/src/views/administrador/areas/AreasList.vue index 21f79a0..eb70c0d 100644 --- a/front/src/views/administrador/areas/AreasList.vue +++ b/front/src/views/administrador/areas/AreasList.vue @@ -122,7 +122,7 @@ @click="showProcessModal(record)" class="action-btn" > - Procesos + P @@ -132,7 +132,7 @@ @click="showCourseModal(record)" class="action-btn" > - Cursos + C - Editar + - Eliminar + diff --git a/front/src/views/administrador/layout/layout.vue b/front/src/views/administrador/layout/layout.vue index 71726b1..e094809 100644 --- a/front/src/views/administrador/layout/layout.vue +++ b/front/src/views/administrador/layout/layout.vue @@ -193,6 +193,12 @@ Cursos + + + @@ -417,6 +423,9 @@ const handleMenuSelect = ({ key }) => { 'examenes-proceso-lista': { name: 'Procesos' }, 'examenes-area-lista': { name: 'Areas' }, 'examenes-curso-lista': { name: 'Cursos' }, + 'examenes-reglas-lista': { name: 'Reglas' }, + + 'lista-areas': { name: 'AcademiaAreas' }, 'lista-cursos': { name: 'AcademiaCursos' }, 'resultados': { name: 'AcademiaResultados' }, @@ -467,6 +476,12 @@ const updatePageInfo = (key) => { subSection: 'cursos', title: 'cursos', subtitle: 'Lista de Cursos' + }, + 'examenes-reglas-lista': { + section: 'Exámenes', + subSection: 'Reglas', + title: 'Reglas', + subtitle: 'Lista de Reglas' } } diff --git a/front/src/views/postulante/Dashboard.vue b/front/src/views/postulante/Dashboard.vue new file mode 100644 index 0000000..14f7ac3 --- /dev/null +++ b/front/src/views/postulante/Dashboard.vue @@ -0,0 +1,355 @@ + + + + + \ No newline at end of file diff --git a/front/src/views/postulante/LoginView.vue b/front/src/views/postulante/LoginView.vue new file mode 100644 index 0000000..db67d33 --- /dev/null +++ b/front/src/views/postulante/LoginView.vue @@ -0,0 +1,1284 @@ + + + + + + \ No newline at end of file diff --git a/front/src/views/postulante/Pagos.vue b/front/src/views/postulante/Pagos.vue new file mode 100644 index 0000000..82d3ea2 --- /dev/null +++ b/front/src/views/postulante/Pagos.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/front/src/views/postulante/PortalView.vue b/front/src/views/postulante/PortalView.vue new file mode 100644 index 0000000..898481c --- /dev/null +++ b/front/src/views/postulante/PortalView.vue @@ -0,0 +1,1210 @@ + + + + + + \ No newline at end of file diff --git a/front/src/views/postulante/PreguntasExamen.vue b/front/src/views/postulante/PreguntasExamen.vue new file mode 100644 index 0000000..a04c9e9 --- /dev/null +++ b/front/src/views/postulante/PreguntasExamen.vue @@ -0,0 +1,664 @@ + + + + + \ No newline at end of file diff --git a/front/src/views/postulante/Resultados.vue b/front/src/views/postulante/Resultados.vue new file mode 100644 index 0000000..4198c3e --- /dev/null +++ b/front/src/views/postulante/Resultados.vue @@ -0,0 +1,827 @@ + + + + + \ No newline at end of file