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 0000000..a3463b7 Binary files /dev/null and b/front/public/logotiny.png differ 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