last_commits

main
Elmer Yujra Condori 2 months ago
parent e16b365df7
commit 53dbbe5382

@ -0,0 +1,322 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\AreaProceso;
use App\Models\Proceso;
use App\Models\ReglaAreaProceso;
use Illuminate\Support\Facades\DB;
class ReglaAreaProcesoController extends Controller
{
/**
* Mostrar cursos de un area_proceso con reglas existentes
*/
public function areasProcesos()
{
$areasProcesos = DB::table('area_proceso as ap')
->leftJoin('reglas_area_proceso as r', 'ap.id', '=', 'r.area_proceso_id')
->leftJoin('area_curso as ac', 'ap.area_id', '=', 'ac.area_id') // pivot area_curso
->leftJoin('cursos as c', 'ac.curso_id', '=', 'c.id') // unimos los cursos reales
->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,
]);
}
}

@ -0,0 +1,500 @@
<?php
namespace App\Http\Controllers;
use App\Models\Postulante;
use App\Models\Proceso;
use App\Models\Area;
use App\Models\Curso;
use App\Models\Pregunta;
use App\Models\Alternativa;
use App\Models\Examen;
use App\Models\PreguntaAsignada;
use App\Models\AlternativaAsignada;
use App\Models\DetalleResultado;
use App\Models\RespuestaPostulante;
use App\Models\ResultadoExamen;
use App\Models\Recomendaciones;
use App\Models\Pago;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Http;
use App\Services\ExamenService;
class ExamenController extends Controller
{
protected $examenService;
public function __construct(ExamenService $examenService)
{
$this->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'
]);
}
}

@ -0,0 +1,269 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Postulante;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Http;
class PostulanteAuthController extends Controller
{
/**
* Registro de postulante (normal) con DNI
*/
public function register(Request $request)
{
$validator = Validator::make($request->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
]);
}
}

@ -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();
}
}

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AreaAdmision extends Model
{
protected $table = 'areas_admision';
protected $fillable = [
'nombre',
'descripcion',
'estado'
];
protected $casts = [
'estado' => 'boolean'
];
/*
|--------------------------------------------------------------------------
| RELACIONES
|--------------------------------------------------------------------------
*/
// Un área tiene muchos resultados
public function resultados()
{
return $this->hasMany(ResultadoAdmision::class, 'idearea');
}
}

@ -0,0 +1,69 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Examen extends Model
{
use HasFactory;
protected $table = 'examenes';
protected $fillable = [
'postulante_id',
'area_proceso_id',
'pagado',
'tipo_pago',
'pago_id',
'intentos',
'hora_inicio',
];
public function postulante()
{
return $this->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');
}
}

@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Pago extends Model
{
use HasFactory;
protected $fillable = [
'postulante_id',
'tipo_pago',
'codigo_pago',
'monto',
'utilizado',
'original_date',
'confirmed_date',
'fecha_pago',
];
}

@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable; // Para usar login
use Laravel\Sanctum\HasApiTokens; // Para tokens API
use Illuminate\Notifications\Notifiable;
class Postulante extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $table = 'postulantes';
/**
* Campos asignables
*/
protected $fillable = [
'name',
'email',
'password',
'dni',
'device_id',
'last_activity'
];
/**
* Campos ocultos al serializar
*/
protected $hidden = [
'password',
'device_id',
'tokens'
];
/**
* Casting
*/
protected $casts = [
'last_activity' => 'datetime',
];
/**
* Mutator para encriptar la contraseña automáticamente
*/
public function setPasswordAttribute($value)
{
$this->attributes['password'] = bcrypt($value);
}
}

@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PreguntaAsignada extends Model
{
protected $table = 'preguntas_asignadas';
protected $fillable = [
'examen_id',
'pregunta_id',
'orden',
'respuesta_usuario',
'es_correcta',
'estado',
'puntaje',
'respondida_at'
];
protected $casts = [
'es_correcta' => '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);
}
}

@ -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
}
}

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ProcesoAdmision extends Model
{
protected $table = 'procesos_admision';
protected $fillable = [
'nombre',
'fecha_inicio',
'fecha_fin',
'estado'
];
protected $casts = [
'fecha_inicio' => 'datetime',
'fecha_fin' => 'datetime',
'estado' => 'boolean'
];
/*
|--------------------------------------------------------------------------
| RELACIONES
|--------------------------------------------------------------------------
*/
// Un proceso tiene muchos resultados
public function resultados()
{
return $this->hasMany(ResultadoAdmision::class, 'idproceso');
}
}

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ReglaAreaProceso extends Model
{
use HasFactory;
protected $table = 'reglas_area_proceso';
protected $fillable = [
'area_proceso_id',
'curso_id',
'cantidad_preguntas',
'orden',
'nivel_dificultad',
'ponderacion',
];
public function areaProceso()
{
return $this->belongsTo(AreaProceso::class);
}
public function curso()
{
return $this->belongsTo(Curso::class);
}
}

@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ResultadoAdmision extends Model
{
protected $table = 'resultados_admision';
protected $fillable = [
'dni',
'paterno',
'materno',
'nombres',
'puntaje',
'vocacional',
'apto',
'obs',
'desprograma',
'idproceso',
'idearea',
'litho',
'numlectura',
'tipo',
'calificar',
'aula',
'respuestas',
'puesto'
];
protected $casts = [
'puntaje' => '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');
}
}

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

@ -0,0 +1,164 @@
<?php
namespace App\Services;
use App\Models\Examen;
use App\Models\Pregunta;
use App\Models\ReglaAreaProceso;
use App\Models\PreguntaAsignada;
use Illuminate\Support\Facades\DB;
class ExamenService
{
/**
* Generar preguntas según reglas
*/
public function generarPreguntasExamen(Examen $examen): array
{
if ($examen->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);
}
}

@ -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,
],
],
/*

@ -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']);
});

@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

@ -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

@ -0,0 +1,167 @@
<template>
<footer class="modern-footer">
<div class="footer-container">
<!-- COLUMNA 1 -->
<div class="footer-col">
<div class="footer-logo">
<img src="/logotiny.png" alt="Logo UNA" />
<div class="footer-title">
<h3>Universidad Nacional del Altiplano</h3>
<span>Dirección de Admisión</span>
</div>
</div>
<p class="footer-text">
Institución pública de educación superior comprometida con la
formación académica, científica y humanística de la región.
</p>
</div>
<!-- COLUMNA 2 -->
<div class="footer-col">
<h4>Admisión</h4>
<ul>
<li>Proceso Ordinario</li>
<li>Proceso Extraordinario</li>
<li>Modalidades</li>
<li>Resultados</li>
</ul>
</div>
<!-- COLUMNA 3 -->
<div class="footer-col">
<h4>Programas</h4>
<ul>
<li>Ingenierías</li>
<li>Biomédicas</li>
<li>Sociales</li>
</ul>
</div>
<!-- COLUMNA 4 -->
<div class="footer-col">
<h4>Contacto</h4>
<ul>
<li>Av. Floral N° 1153 Puno</li>
<li>📞 (051) 123-456</li>
<li> admision@unap.edu.pe</li>
</ul>
</div>
</div>
<!-- FOOTER BOTTOM -->
<div class="footer-bottom">
© {{ new Date().getFullYear() }} Universidad Nacional del Altiplano.
Todos los derechos reservados.
</div>
</footer>
</template>
<script setup>
</script>
<style scoped>
/* FUENTE */
.modern-footer,
.footer-col h4,
.footer-col li,
.footer-text,
.footer-bottom {
font-family: "Times New Roman", Times, serif;
}
/* FOOTER BASE */
.modern-footer {
background: #f9fafb;
border-top: 1px solid #d1d5db;
margin-top: 60px;
}
/* CONTAINER */
.footer-container {
max-width: 1320px;
margin: auto;
padding: 48px 24px;
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 32px;
}
/* LOGO */
.footer-logo {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 14px;
}
.footer-logo img {
width: 46px;
height: 46px;
border: 1px solid #cbd5e1;
border-radius: 6px;
object-fit: contain;
}
.footer-title h3 {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: #111827;
}
.footer-title span {
font-size: 0.8rem;
color: #374151;
}
/* TEXT */
.footer-text {
font-size: 0.9rem;
color: #374151;
line-height: 1.6;
max-width: 360px;
}
/* COLUMNS */
.footer-col h4 {
margin-bottom: 12px;
font-size: 0.95rem;
font-weight: 700;
color: #111827;
}
.footer-col ul {
list-style: none;
padding: 0;
margin: 0;
}
.footer-col li {
font-size: 0.88rem;
color: #1f2937;
margin-bottom: 8px;
}
/* BOTTOM */
.footer-bottom {
border-top: 1px solid #e5e7eb;
padding: 14px 24px;
text-align: center;
font-size: 0.82rem;
color: #374151;
}
/* RESPONSIVE */
@media (max-width: 900px) {
.footer-container {
grid-template-columns: 1fr;
padding: 36px 24px;
}
.footer-text {
max-width: 100%;
}
}
</style>

File diff suppressed because it is too large Load Diff

@ -1,615 +0,0 @@
<!-- components/Postulante/VinculacionAcademia.vue -->
<template>
<div class="vinculacion-modal">
<div class="modal-content">
<!-- Paso 1: Ingresar código -->
<div class="step-ingreso" v-if="paso === 1">
<div class="step-header">
<div class="step-number active">1</div>
<div class="step-info">
<h3>Ingresa el código de academia</h3>
<p>Pide el código a tu profesor o coordinador</p>
</div>
</div>
<a-form
:model="formState"
layout="vertical"
@finish="validarCodigo"
class="codigo-form"
>
<a-form-item
label="Código de Academia"
:rules="[
{ required: true, message: 'Ingresa el código' },
{ pattern: /^[A-Z0-9\-]+$/, message: 'Solo letras mayúsculas, números y guiones' }
]"
>
<a-input
v-model:value="formState.codigo"
placeholder="Ej: AC001-2024"
size="large"
:disabled="vinculacionStore.isLoading"
@input="formatearCodigo"
>
<template #prefix>
<KeyOutlined style="color: #666" />
</template>
<template #suffix>
<a-tooltip title="¿Cómo obtener el código?">
<QuestionCircleOutlined
@click="mostrarAyuda"
style="cursor: pointer; color: #666"
/>
</a-tooltip>
</template>
</a-input>
</a-form-item>
<a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
:loading="vinculacionStore.isLoading"
block
>
Validar Código
</a-button>
</a-form-item>
</a-form>
<!-- Códigos recientes -->
<div class="codigos-recientes" v-if="codigosRecientes.length > 0">
<h4>Códigos recientes</h4>
<a-space :size="8" wrap>
<a-tag
v-for="codigo in codigosRecientes"
:key="codigo"
color="blue"
style="cursor: pointer"
@click="usarCodigoReciente(codigo)"
>
{{ codigo }}
<CloseOutlined @click.stop="eliminarCodigoReciente(codigo)" />
</a-tag>
</a-space>
</div>
<!-- Ayuda QR -->
<div class="qr-section">
<p class="qr-title">
<QrcodeOutlined /> También puedes usar QR
</p>
<div class="qr-actions">
<a-button @click="escanearQR">
<CameraOutlined />
Escanear QR
</a-button>
<a-button @click="mostrarEjemploQR">
<EyeOutlined />
Ver ejemplo
</a-button>
</div>
</div>
</div>
<!-- Paso 2: Confirmar vinculación -->
<div class="step-confirmacion" v-else-if="paso === 2 && vinculacionStore.codigoValidado">
<div class="step-header">
<div class="step-number active">2</div>
<div class="step-info">
<h3>Confirmar vinculación</h3>
<p>Revisa los detalles antes de vincular</p>
</div>
</div>
<a-card class="academia-info-card">
<a-space direction="vertical" size="large" style="width: 100%">
<!-- Información de la academia -->
<div class="academia-details">
<div class="academia-header">
<a-avatar :size="48" :style="{ backgroundColor: stringToColor(vinculacionStore.codigoValidado.nombre) }">
{{ vinculacionStore.codigoValidado.nombre.charAt(0).toUpperCase() }}
</a-avatar>
<div class="academia-titulo">
<h3 style="margin: 0">{{ vinculacionStore.codigoValidado.nombre }}</h3>
<p style="margin: 0; color: #666">
Código: <strong>{{ vinculacionStore.codigoValidado.codigo }}</strong>
</p>
</div>
</div>
<a-divider style="margin: 16px 0" />
<!-- Estadísticas -->
<a-row :gutter="16" class="academia-stats">
<a-col :span="12">
<div class="stat-item">
<TeamOutlined style="color: #1890ff; font-size: 20px" />
<div class="stat-info">
<div class="stat-value">{{ vinculacionStore.codigoValidado.estudiantes_activos }}</div>
<div class="stat-label">Estudiantes</div>
</div>
</div>
</a-col>
<a-col :span="12">
<div class="stat-item">
<FileTextOutlined style="color: #52c41a; font-size: 20px" />
<div class="stat-info">
<div class="stat-value">+{{ examenesCount }}</div>
<div class="stat-label">Exámenes</div>
</div>
</div>
</a-col>
</a-row>
<!-- Límite de estudiantes -->
<a-alert
v-if="vinculacionStore.codigoValidado.estudiantes_activos >= vinculacionStore.codigoValidado.limite_estudiantes * 0.9"
type="warning"
message="Cupo casi lleno"
:description="`${vinculacionStore.codigoValidado.estudiantes_activos}/${vinculacionStore.codigoValidado.limite_estudiantes} estudiantes`"
show-icon
style="margin-top: 16px"
/>
<!-- Estado -->
<div class="academia-status">
<a-tag :color="vinculacionStore.codigoValidado.activa ? 'green' : 'red'">
{{ vinculacionStore.codigoValidado.activa ? 'Activa' : 'Inactiva' }}
</a-tag>
<span v-if="vinculacionStore.codigoValidado.ya_vinculado" class="ya-vinculado">
<CheckCircleOutlined /> Ya estás vinculado
</span>
</div>
</div>
<!-- Acciones -->
<div class="acciones-confirmacion">
<a-space :size="16" style="width: 100%">
<a-button
@click="paso = 1"
:disabled="vinculacionStore.isLoading"
block
>
<LeftOutlined />
Volver
</a-button>
<a-button
type="primary"
@click="confirmarVinculacion"
:loading="vinculacionStore.isLoading"
:disabled="
vinculacionStore.codigoValidado.ya_vinculado ||
!vinculacionStore.codigoValidado.activa ||
vinculacionStore.codigoValidado.estudiantes_activos >= vinculacionStore.codigoValidado.limite_estudiantes
"
block
>
<template v-if="vinculacionStore.codigoValidado.ya_vinculado">
Ya vinculado
</template>
<template v-else>
<LinkOutlined />
Vincularme
</template>
</a-button>
</a-space>
</div>
</a-space>
</a-card>
</div>
<!-- Paso 3: Éxito -->
<div class="step-exito" v-else-if="paso === 3">
<div class="exito-content">
<div class="exito-icon">
<CheckCircleOutlined style="font-size: 64px; color: #52c41a" />
</div>
<h2>¡Vinculación exitosa!</h2>
<p>Ahora formas parte de <strong>{{ academiaVinculada?.nombre }}</strong></p>
<div class="exito-details">
<a-card size="small">
<a-list size="small">
<a-list-item>
<template #actions>
<a-tag color="blue">{{ academiaVinculada?.codigo }}</a-tag>
</template>
<a-list-item-meta>
<template #title>
Código de academia
</template>
</a-list-item-meta>
</a-list-item>
<a-list-item>
<template #actions>
<span>{{ examenesDisponibles }} disponibles</span>
</template>
<a-list-item-meta>
<template #title>
Exámenes
</template>
</a-list-item-meta>
</a-list-item>
</a-list>
</a-card>
</div>
<div class="exito-acciones">
<a-space :size="16" style="width: 100%">
<a-button @click="cerrarModal" block>
Continuar
</a-button>
<a-button type="primary" @click="irAExamenes" block>
<EyeOutlined />
Ver exámenes
</a-button>
</a-space>
</div>
</div>
</div>
<!-- Error -->
<div class="step-error" v-else-if="paso === 'error'">
<div class="error-content">
<div class="error-icon">
<CloseCircleOutlined style="font-size: 64px; color: #ff4d4f" />
</div>
<h2>Error en la vinculación</h2>
<p>{{ errorMensaje }}</p>
<div class="error-sugerencias">
<a-alert
type="info"
message="Sugerencias:"
description="1. Verifica que el código sea correcto
2. Asegúrate de que la academia esté activa
3. Contacta al administrador de la academia"
show-icon
/>
</div>
<div class="error-acciones">
<a-space :size="16" style="width: 100%">
<a-button @click="reiniciarProceso" type="primary" block>
<ReloadOutlined />
Intentar nuevamente
</a-button>
<a-button @click="cerrarModal" block>
Cancelar
</a-button>
</a-space>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { useVinculacionStore } from '../../store/vinculacion'
import {
KeyOutlined,
QuestionCircleOutlined,
CloseOutlined,
QrcodeOutlined,
CameraOutlined,
EyeOutlined,
TeamOutlined,
FileTextOutlined,
LeftOutlined,
LinkOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ReloadOutlined
} from '@ant-design/icons-vue'
const vinculacionStore = useVinculacionStore()
const emit = defineEmits(['cerrar'])
const paso = ref(1)
const formState = reactive({
codigo: ''
})
const codigosRecientes = ref([])
const academiaVinculada = ref(null)
const errorMensaje = ref('')
const examenesCount = ref(15) // Esto vendría del backend
onMounted(() => {
cargarCodigosRecientes()
})
const cargarCodigosRecientes = () => {
const codigos = localStorage.getItem('codigos_recientes')
if (codigos) {
codigosRecientes.value = JSON.parse(codigos)
}
}
const guardarCodigoReciente = (codigo) => {
const codigos = new Set([codigo, ...codigosRecientes.value])
codigosRecientes.value = Array.from(codigos).slice(0, 5)
localStorage.setItem('codigos_recientes', JSON.stringify(codigosRecientes.value))
}
const eliminarCodigoReciente = (codigo) => {
codigosRecientes.value = codigosRecientes.value.filter(c => c !== codigo)
localStorage.setItem('codigos_recientes', JSON.stringify(codigosRecientes.value))
}
const usarCodigoReciente = (codigo) => {
formState.codigo = codigo
validarCodigo()
}
const formatearCodigo = () => {
formState.codigo = formState.codigo.toUpperCase().replace(/[^A-Z0-9\-]/g, '')
}
const validarCodigo = async () => {
if (!formState.codigo.trim()) {
message.error('Ingresa un código válido')
return
}
try {
await vinculacionStore.validarCodigo(formState.codigo)
guardarCodigoReciente(formState.codigo)
paso.value = 2
} catch (error) {
// Error manejado en el store
}
}
const confirmarVinculacion = async () => {
try {
const resultado = await vinculacionStore.vincularAcademia(formState.codigo)
academiaVinculada.value = resultado.academia
paso.value = 3
} catch (error) {
paso.value = 'error'
errorMensaje.value = vinculacionStore.getError || 'Error al vincular con la academia'
}
}
const mostrarAyuda = () => {
message.info('El código te lo debe proporcionar tu profesor o coordinador de la academia')
}
const escanearQR = () => {
message.info('Función de escaneo de QR en desarrollo')
}
const mostrarEjemploQR = () => {
message.info('El QR contiene el código de la academia para escanear')
}
const stringToColor = (str) => {
let hash = 0
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash)
}
const colors = ['#1890ff', '#52c41a', '#722ed1', '#fa8c16', '#f5222d']
return colors[Math.abs(hash) % colors.length]
}
const cerrarModal = () => {
vinculacionStore.hideModalVinculacion()
reiniciarProceso()
}
const irAExamenes = () => {
cerrarModal()
// Navegar a exámenes
router.push('/postulante/examenes')
}
const reiniciarProceso = () => {
paso.value = 1
formState.codigo = ''
academiaVinculada.value = null
errorMensaje.value = ''
}
const examenesDisponibles = ref(3) // Esto vendría del backend
</script>
<style scoped>
.vinculacion-modal {
padding: 24px;
}
.modal-content {
max-width: 500px;
margin: 0 auto;
}
/* Pasos */
.step-header {
display: flex;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.step-number {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
background: #f0f0f0;
color: #999;
}
.step-number.active {
background: #1890ff;
color: white;
}
.step-info h3 {
margin: 0 0 4px 0;
font-size: 18px;
}
.step-info p {
margin: 0;
color: #666;
font-size: 14px;
}
/* Formulario */
.codigo-form {
margin-bottom: 24px;
}
/* Códigos recientes */
.codigos-recientes {
margin-bottom: 24px;
}
.codigos-recientes h4 {
margin: 0 0 8px 0;
font-size: 14px;
color: #666;
}
/* QR Section */
.qr-section {
background: #fafafa;
border-radius: 8px;
padding: 16px;
text-align: center;
}
.qr-title {
margin: 0 0 12px 0;
font-weight: 500;
}
.qr-actions {
display: flex;
gap: 8px;
justify-content: center;
}
/* Academia Info Card */
.academia-info-card {
margin-bottom: 24px;
}
.academia-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.academia-titulo {
flex: 1;
}
.academia-stats {
margin: 16px 0;
}
.stat-item {
display: flex;
align-items: center;
gap: 12px;
}
.stat-value {
font-size: 20px;
font-weight: 600;
line-height: 1;
}
.stat-label {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.academia-status {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
}
.ya-vinculado {
color: #52c41a;
font-size: 14px;
}
/* Éxito y Error */
.exito-content,
.error-content {
text-align: center;
padding: 24px 0;
}
.exito-icon,
.error-icon {
margin-bottom: 24px;
}
.exito-content h2,
.error-content h2 {
margin: 0 0 8px 0;
color: #333;
}
.exito-content p,
.error-content p {
margin: 0 0 24px 0;
color: #666;
}
.exito-details,
.error-sugerencias {
margin-bottom: 24px;
}
.exito-acciones,
.error-acciones {
max-width: 400px;
margin: 0 auto;
}
/* Responsive */
@media (max-width: 576px) {
.vinculacion-modal {
padding: 16px;
}
.step-header {
flex-direction: column;
gap: 8px;
text-align: center;
}
.step-number {
align-self: center;
}
.qr-actions {
flex-direction: column;
}
.academia-header {
flex-direction: column;
text-align: center;
gap: 12px;
}
}
</style>

@ -1,307 +0,0 @@
<!-- components/SuperAdmin/AcademiasList.vue -->
<template>
<div class="academias-list">
<a-card title="Gestión de Academias" class="mb-4">
<template #extra>
<a-space>
<a-input-search
v-model:value="searchText"
placeholder="Buscar academias..."
style="width: 250px"
@search="handleSearch"
/>
<a-button type="primary" @click="showModal = true">
<template #icon><plus-outlined /></template>
Nueva Academia
</a-button>
</a-space>
</template>
<a-table
:columns="columns"
:data-source="academiaStore.getAcademias"
:loading="academiaStore.isLoading"
:pagination="academiaStore.getPagination"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'codigo'">
<a-tag color="blue">{{ record.codigo }}</a-tag>
</template>
<template v-if="column.key === 'estado'">
<a-tag :color="record.activa ? 'green' : 'red'">
{{ record.activa ? 'Activa' : 'Inactiva' }}
</a-tag>
</template>
<template v-if="column.key === 'estudiantes'">
<span>{{ record.estudiantes_activos }} / {{ record.limite_estudiantes }}</span>
</template>
<template v-if="column.key === 'admin'">
<span>{{ record.admin_academia?.name || 'No asignado' }}</span>
</template>
<template v-if="column.key === 'acciones'">
<a-space>
<a-button type="link" size="small" @click="verDetalles(record)">
<eye-outlined />
</a-button>
<a-button type="link" size="small" @click="editarAcademia(record)">
<edit-outlined />
</a-button>
<a-popconfirm
title="¿Estás seguro de eliminar esta academia?"
@confirm="eliminarAcademia(record.id)"
>
<a-button type="link" danger size="small">
<delete-outlined />
</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- Modal para crear/editar academia -->
<a-modal
v-model:open="showModal"
:title="modalTitle"
width="600px"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formState"
:rules="rules"
layout="vertical"
@finish="handleSubmit"
>
<a-form-item label="Nombre" name="nombre">
<a-input
v-model:value="formState.nombre"
placeholder="Nombre de la academia"
/>
</a-form-item>
<a-form-item label="Descripción" name="descripcion">
<a-textarea
v-model:value="formState.descripcion"
placeholder="Descripción de la academia"
:rows="3"
/>
</a-form-item>
<a-form-item label="Límite de Estudiantes" name="limite_estudiantes">
<a-input-number
v-model:value="formState.limite_estudiantes"
:min="1"
:max="10000"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="Administrador" name="admin_academia_id" required>
<a-select
v-model:value="formState.admin_academia_id"
placeholder="Seleccionar administrador"
:loading="loadingAdmins"
show-search
option-filter-prop="label"
>
<a-select-option
v-for="admin in administradores"
:key="admin.id"
:value="admin.id"
:label="admin.name"
>
{{ admin.name }} ({{ admin.email }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Estado" name="activa">
<a-switch
v-model:checked="formState.activa"
checked-children="Activa"
un-checked-children="Inactiva"
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import {
PlusOutlined,
EyeOutlined,
EditOutlined,
DeleteOutlined
} from '@ant-design/icons-vue'
import { useAcademiaStore } from '../../stores/academia'
import { useUserStore } from '../../stores/user'
const academiaStore = useAcademiaStore()
const userStore = useUserStore()
const searchText = ref('')
const showModal = ref(false)
const formRef = ref()
const loadingAdmins = ref(false)
const administradores = ref([])
const editingId = ref(null)
const columns = [
{
title: 'Código',
dataIndex: 'codigo',
key: 'codigo',
width: 150
},
{
title: 'Nombre',
dataIndex: 'nombre',
key: 'nombre',
ellipsis: true
},
{
title: 'Estado',
key: 'estado',
width: 100
},
{
title: 'Estudiantes',
key: 'estudiantes',
width: 150
},
{
title: 'Administrador',
key: 'admin',
ellipsis: true
},
{
title: 'Creada',
dataIndex: 'created_at',
key: 'created_at',
width: 150,
customRender: ({ text }) => new Date(text).toLocaleDateString()
},
{
title: 'Acciones',
key: 'acciones',
width: 150
}
]
const formState = reactive({
nombre: '',
descripcion: '',
limite_estudiantes: 100,
admin_academia_id: null,
activa: true
})
const rules = {
nombre: [
{ required: true, message: 'Por favor ingresa el nombre' },
{ max: 255, message: 'Máximo 255 caracteres' }
],
admin_academia_id: [
{ required: true, message: 'Por favor selecciona un administrador' }
]
}
const modalTitle = computed(() =>
editingId.value ? 'Editar Academia' : 'Nueva Academia'
)
onMounted(() => {
academiaStore.fetchAcademias()
cargarAdministradores()
})
const cargarAdministradores = async () => {
try {
loadingAdmins.value = true
// Aquí deberías implementar un endpoint para obtener usuarios con rol administrador
const response = await api.get('/usuarios?role=administrador')
administradores.value = response.data
} catch (error) {
message.error('Error al cargar administradores')
} finally {
loadingAdmins.value = false
}
}
const handleSearch = () => {
academiaStore.setFiltros({ search: searchText.value })
}
const handleTableChange = (pagination) => {
academiaStore.setPagination({
current: pagination.current,
pageSize: pagination.pageSize
})
}
const verDetalles = (academia) => {
// Navegar a la vista de detalles
router.push(`/admin/academias/${academia.id}`)
}
const editarAcademia = (academia) => {
editingId.value = academia.id
Object.assign(formState, {
nombre: academia.nombre,
descripcion: academia.descripcion,
limite_estudiantes: academia.limite_estudiantes,
admin_academia_id: academia.admin_academia_id,
activa: academia.activa
})
showModal.value = true
}
const eliminarAcademia = async (id) => {
await academiaStore.deleteAcademia(id)
}
const handleModalOk = () => {
formRef.value.validateFields().then(async () => {
if (editingId.value) {
await academiaStore.updateAcademia(editingId.value, formState)
} else {
await academiaStore.createAcademia(formState)
}
showModal.value = false
resetForm()
})
}
const handleModalCancel = () => {
resetForm()
}
const resetForm = () => {
formRef.value?.resetFields()
editingId.value = null
Object.assign(formState, {
nombre: '',
descripcion: '',
limite_estudiantes: 100,
admin_academia_id: null,
activa: true
})
}
</script>
<style scoped>
.academias-list {
padding: 20px;
}
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,658 @@
<template>
<a-layout-header class="modern-header">
<div class="header-container">
<!-- LOGO -->
<div
class="university-logo"
@click="handleLogoClick"
style="cursor: pointer;"
>
<div class="logo-icon">
<img src="/logotiny.png" alt="Logo UNA" />
</div>
<div class="logo-text">
<h1>Universidad Nacional del Altiplano</h1>
<span>Dirección de Admisión</span>
</div>
</div>
<!-- DESKTOP NAV -->
<nav class="modern-nav desktop-only">
<a-menu
v-model:selectedKeys="selectedKeys"
mode="horizontal"
class="nav-menu-modern"
:items="navItems"
@click="handleMenuClick"
@openChange="handleDesktopOpenChange"
/>
</nav>
<!-- MOBILE MENU BUTTON -->
<a-button
class="mobile-menu-btn mobile-only"
type="text"
@click="drawerOpen = true"
>
</a-button>
</div>
<!-- MOBILE DRAWER -->
<a-drawer
title="Menú"
placement="right"
:open="drawerOpen && isMobile"
@close="handleDrawerClose"
:width="280"
:bodyStyle="{ padding: 0 }"
:headerStyle="{ borderBottom: '1px solid #f0f0f0', fontFamily: 'Times New Roman' }"
:closable="true"
:maskClosable="true"
>
<div class="drawer-content">
<div class="drawer-header">
<div class="drawer-logo">
<div class="logo-icon">
<img src="/logotiny.png" alt="Logo UNA" />
</div>
<div class="drawer-logo-text">
<h3>UNA</h3>
<span>Dirección de Admisión</span>
</div>
</div>
</div>
<a-menu
v-model:selectedKeys="selectedKeys"
mode="inline"
:items="mobileNavItems"
class="drawer-menu"
@click="handleDrawerMenuClick"
@openChange="handleMobileOpenChange"
:openKeys="mobileOpenKeys"
/>
</div>
<div class="auth-section">
<router-link
v-if="!authStore.isAuthenticated"
to="/login-postulante"
class="login-link"
>
<a-button type="primary">
<template #icon><UserOutlined /></template>
Portal del Postulante
</a-button>
</router-link>
<router-link
v-else
to="/portal"
class="portal-link"
>
<a-button type="primary" ghost>
<template #icon><DashboardOutlined /></template>
Mi Portal
</a-button>
</router-link>
</div>
</a-drawer>
<div class="auth-section">
<router-link
v-if="!authStore.isAuthenticated"
to="/login-postulante"
class="login-link"
>
<a-button type="primary">
<template #icon><UserOutlined /></template>
Portal del Postulante
</a-button>
</router-link>
<router-link
v-else
to="/portal-postulante"
class="portal-link"
>
<a-button type="primary" ghost>
<template #icon><DashboardOutlined /></template>
Mi Portal
</a-button>
</router-link>
</div>
</a-layout-header>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useAuthStore } from '../store/postulanteStore'
import { UserOutlined, DashboardOutlined } from '@ant-design/icons-vue'
const drawerOpen = ref(false)
const selectedKeys = ref(['inicio'])
const isMobile = ref(false)
const authStore = useAuthStore()
const desktopOpenKeys = ref([])
const mobileOpenKeys = ref([])
// Detectar tamaño de pantalla
const checkScreenSize = () => {
isMobile.value = window.innerWidth < 768
// Si cambia a desktop, cerrar el drawer
if (!isMobile.value && drawerOpen.value) {
drawerOpen.value = false
mobileOpenKeys.value = [] // Limpiar openKeys del móvil
}
}
// Items del menú para desktop
const navItems = computed(() => [
{
key: 'inicio',
label: 'Inicio'
},
{
key: 'programas',
label: 'Programas',
children: [
{ key: 'ingenierias', label: 'Ingenierías' },
{ key: 'biomedicas', label: 'Biomédicas' },
{ key: 'sociales', label: 'Sociales' }
]
},
{
key: 'procesos',
label: 'Procesos'
},
{
key: 'modalidades',
label: 'Modalidades',
children: [
{ key: 'ordinario', label: 'Ordinario' },
{ key: 'extraordinario', label: 'Extraordinario' },
{ key: 'sedes', label: 'Sedes' }
]
},
{
key: 'resultados',
label: 'Resultados'
}
])
// Items del menú para móvil con submenús contraídos
const mobileNavItems = computed(() => {
return navItems.value.map(item => {
const baseItem = {
key: item.key,
label: item.label
}
if (item.children) {
// Submenú plegable en móvil
return {
...baseItem,
children: item.children.map(child => ({
key: child.key,
label: child.label
}))
}
}
return baseItem
})
})
// Observar cambios en el drawer para limpiar openKeys cuando se cierra
watch(drawerOpen, (newVal) => {
if (!newVal) {
// Limpiar openKeys cuando se cierra el drawer
mobileOpenKeys.value = []
}
})
// Observar cambios en isMobile para cerrar drawer si cambia a desktop
watch(isMobile, (newVal) => {
if (!newVal && drawerOpen.value) {
drawerOpen.value = false
}
})
const handleMenuClick = ({ key }) => {
selectedKeys.value = [key]
console.log('Navegando a:', key)
// Si es un item de submenú, cerrar el drawer en móvil
const isSubmenuItem = navItems.value.some(item =>
item.children && item.children.some(child => child.key === key)
)
if (isSubmenuItem && isMobile.value) {
drawerOpen.value = false
}
}
const handleDrawerMenuClick = ({ key }) => {
handleMenuClick({ key })
// Si NO es un submenú padre, cerrar el drawer
const isParentItem = navItems.value.some(item =>
item.key === key && item.children
)
if (!isParentItem && isMobile.value) {
drawerOpen.value = false
}
}
const handleLogoClick = () => {
selectedKeys.value = ['inicio']
// router.push('/')
}
const handleDesktopOpenChange = (keys) => {
desktopOpenKeys.value = keys
}
const handleMobileOpenChange = (keys) => {
// En móvil, solo permitir un submenú abierto a la vez
const latestOpenKey = keys.find(key => !mobileOpenKeys.value.includes(key))
if (latestOpenKey) {
mobileOpenKeys.value = [latestOpenKey]
} else {
mobileOpenKeys.value = []
}
}
const handleDrawerClose = () => {
// Limpiar openKeys y cerrar drawer
mobileOpenKeys.value = []
drawerOpen.value = false
}
// Configurar event listeners para responsive
onMounted(() => {
checkScreenSize()
window.addEventListener('resize', checkScreenSize)
})
onUnmounted(() => {
window.removeEventListener('resize', checkScreenSize)
})
</script>
<style scoped>
/* VARIABLES */
:root {
--primary-color: #1e3a8a;
--secondary-color: #374151;
--border-color: #d1d5db;
--bg-color: #ffffff;
}
/* FUENTE INSTITUCIONAL */
.modern-header,
.nav-menu-modern,
.logo-text h1,
.logo-text span,
.drawer-logo-text h3,
.drawer-logo-text span {
font-family: "Times New Roman", Times, serif;
}
/* HEADER */
.modern-header {
background: rgba(255, 255, 255, 0.92) !important;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border-color);
padding: 0 24px !important;
height: 82px !important;
line-height: 82px !important;
position: sticky;
top: 0;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
/* CONTAINER */
.header-container {
max-width: 1320px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
width: 100%;
}
/* LOGO */
.university-logo {
display: flex;
align-items: center;
gap: 14px;
min-width: 250px;
transition: opacity 0.3s;
}
.university-logo:hover {
opacity: 0.9;
}
.logo-icon {
width: 46px;
height: 46px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.logo-icon img {
width: 90%;
height: 90%;
object-fit: contain;
}
.logo-text {
display: flex;
flex-direction: column;
line-height: 1.4;
}
.logo-text h1 {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
color: var(--primary-color);
}
.logo-text span {
font-size: 0.8rem;
color: var(--secondary-color);
}
/* NAV DESKTOP */
.modern-nav {
flex: 1;
display: flex;
justify-content: center;
min-width: 0;
}
.nav-menu-modern {
flex: 1;
background: transparent !important;
border-bottom: none !important;
justify-content: center;
}
.nav-menu-modern :deep(.ant-menu-item),
.nav-menu-modern :deep(.ant-menu-submenu) {
height: 82px;
line-height: 82px;
padding: 0 16px;
font-size: 0.95rem;
color: var(--secondary-color);
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
}
.nav-menu-modern :deep(.ant-menu-item:hover),
.nav-menu-modern :deep(.ant-menu-submenu:hover) {
color: var(--primary-color);
}
.nav-menu-modern :deep(.ant-menu-item-selected),
.nav-menu-modern :deep(.ant-menu-submenu-selected) {
color: var(--primary-color);
font-weight: 600;
}
.nav-menu-modern :deep(.ant-menu-item-selected::after),
.nav-menu-modern :deep(.ant-menu-submenu-selected::after) {
border-bottom: 3px solid var(--primary-color) !important;
}
.nav-menu-modern :deep(.ant-menu-submenu-arrow) {
color: var(--secondary-color);
}
.nav-menu-modern :deep(.ant-menu-submenu:hover .ant-menu-submenu-arrow) {
color: var(--primary-color);
}
/* BOTÓN MÓVIL */
.mobile-menu-btn {
font-size: 24px;
color: #111827;
padding: 8px;
border-radius: 4px;
transition: background-color 0.3s;
}
.mobile-menu-btn:hover {
background: rgba(0, 0, 0, 0.04);
}
/* DRAWER MÓVIL */
.drawer-content {
display: flex;
flex-direction: column;
height: 100%;
}
.drawer-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
background: #fafafa;
}
.drawer-logo {
display: flex;
align-items: center;
gap: 12px;
}
.drawer-logo-text {
display: flex;
flex-direction: column;
line-height: 1.4;
}
.drawer-logo-text h3 {
margin: 0;
color: var(--primary-color);
font-size: 1rem;
}
.drawer-logo-text span {
font-size: 12px;
color: var(--secondary-color);
}
.drawer-menu {
border-right: none !important;
}
.drawer-menu :deep(.ant-menu-item) {
font-size: 15px;
padding-left: 24px !important;
height: 48px;
line-height: 48px;
margin: 4px 0;
border-radius: 0;
transition: background-color 0.3s;
}
.drawer-menu :deep(.ant-menu-item:hover) {
background-color: #f0f7ff;
}
.drawer-menu :deep(.ant-menu-item-selected) {
background-color: #f0f7ff;
color: var(--primary-color);
font-weight: 500;
}
.drawer-menu :deep(.ant-menu-item-selected::after) {
border-right: 3px solid var(--primary-color);
}
.drawer-menu :deep(.ant-menu-submenu-title) {
font-size: 15px;
padding-left: 24px !important;
height: 48px;
line-height: 48px;
margin: 4px 0;
border-radius: 0;
transition: background-color 0.3s;
}
.drawer-menu :deep(.ant-menu-submenu-title:hover) {
background-color: #f0f7ff;
}
.drawer-menu :deep(.ant-menu-submenu-selected > .ant-menu-submenu-title) {
color: var(--primary-color);
font-weight: 500;
}
.drawer-menu :deep(.ant-menu-submenu-arrow) {
font-size: 12px !important;
}
.drawer-menu :deep(.ant-menu-submenu .ant-menu-item) {
padding-left: 48px !important;
font-size: 14px;
}
/* RESPONSIVE */
.desktop-only {
display: flex;
}
.mobile-only {
display: none;
}
/* TABLET (768px - 992px) */
@media (max-width: 992px) {
.modern-header {
padding: 0 16px !important;
}
.header-container {
padding: 0 8px;
}
.logo-text h1 {
font-size: 14px !important;
}
.logo-text span {
font-size: 12px !important;
}
.nav-menu-modern :deep(.ant-menu-item),
.nav-menu-modern :deep(.ant-menu-submenu) {
padding: 0 12px;
font-size: 0.9rem;
}
}
/* MOBILE (< 768px) */
@media (max-width: 768px) {
.desktop-only {
display: none;
}
.mobile-only {
display: inline-flex;
}
.modern-header {
height: 72px !important;
line-height: 72px !important;
}
.header-container {
padding: 0;
}
.university-logo {
min-width: auto;
gap: 10px;
}
.logo-icon {
width: 40px;
height: 40px;
}
.logo-text h1 {
font-size: 13px !important;
line-height: 1.2;
}
.logo-text span {
font-size: 11px !important;
}
}
/* SMALL MOBILE (< 480px) */
@media (max-width: 480px) {
.modern-header {
padding: 0 12px !important;
}
.logo-text h1 {
font-size: 12px !important;
}
.logo-text span {
display: none;
}
.mobile-menu-btn {
font-size: 20px;
padding: 4px;
}
.drawer-menu :deep(.ant-menu-submenu-title),
.drawer-menu :deep(.ant-menu-item) {
height: 44px;
line-height: 44px;
font-size: 14px;
}
.drawer-menu :deep(.ant-menu-submenu .ant-menu-item) {
height: 40px;
line-height: 40px;
font-size: 13px;
}
}
/* IMPRESIÓN */
@media print {
.modern-header {
position: static;
box-shadow: none;
border-bottom: 2px solid #000;
}
.mobile-menu-btn {
display: none !important;
}
}
</style>

@ -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

@ -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;
}
}
}
});

@ -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
}
}
})

@ -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
}
})

@ -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
},
}
})

@ -206,7 +206,7 @@
<!-- Modal para nuevo/editar curso -->
<a-modal
v-model:visible="showModal"
v-model:open="showModal"
:title="modalTitle"
width="600px"
@ok="handleModalOk"

@ -0,0 +1,769 @@
<template>
<div class="reglas-container">
<!-- Header -->
<div class="reglas-header">
<div class="header-title">
<h2>Reglas de Evaluación</h2>
<p class="subtitle">Configuración de reglas por área-proceso</p>
</div>
<a-button v-if="store.areaProcesoId" @click="volverALista" size="large">
<template #icon><ArrowLeftOutlined /></template>
Volver
</a-button>
</div>
<!-- Selección de Área Proceso -->
<div v-if="!store.areaProcesoId" class="seleccion-section">
<div class="card">
<div class="card-header">
<h3>Seleccionar Área Proceso</h3>
<p>Selecciona un área-proceso para configurar sus reglas</p>
</div>
<div class="card-body">
<!-- Filtros -->
<div class="filters">
<a-input-search
v-model:value="filtroBusqueda"
placeholder="Buscar área..."
@search="filtrarAreas"
style="width: 300px"
/>
<a-select
v-model:value="filtroProceso"
placeholder="Filtrar por proceso"
style="width: 250px; margin-left: 16px"
@change="filtrarAreas"
>
<a-select-option :value="null">Todos los procesos</a-select-option>
<a-select-option
v-for="proceso in procesosUnicos"
:key="proceso.id"
:value="proceso.id"
>
{{ proceso.nombre }}
</a-select-option>
</a-select>
<a-button @click="limpiarFiltros" style="margin-left: 16px">
<ReloadOutlined /> Limpiar
</a-button>
</div>
<!-- Tabla de Áreas Proceso -->
<a-table
:data-source="areasProcesoFiltradas"
:columns="columnasAreas"
:loading="store.cargando"
:pagination="{ pageSize: 10 }"
row-key="id"
@rowClick="seleccionarAreaProceso"
class="clickable-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'cursos'">
<a-tag color="blue">{{ record.cursos_count || 0 }} cursos</a-tag>
</template>
<template v-if="column.key === 'reglas_count'">
<a-tag :color="record.reglas_count > 0 ? 'green' : 'default'">
{{ record.reglas_count || 0 }} reglas
</a-tag>
</template>
<template v-if="column.key === 'accion'">
<a-button type="primary" size="small" @click.stop="seleccionarAreaProceso(record)">
Configurar
</a-button>
</template>
</template>
</a-table>
</div>
</div>
</div>
<!-- Configuración de Reglas -->
<div v-else class="configuracion-section">
<!-- Header del Proceso -->
<div class="proceso-info">
<div class="proceso-header">
<h3>{{ store.proceso?.nombre }} <span class="separator">/</span> {{ nombreAreaActual }}</h3>
<div class="proceso-stats">
<div class="stat"><span class="stat-label">Total Preguntas:</span> <span class="stat-value">{{ store.totalPreguntasProceso }}</span></div>
<div class="stat"><span class="stat-label">Asignadas:</span> <span class="stat-value">{{ store.totalPreguntasAsignadas }}</span></div>
<div class="stat"><span class="stat-label">Disponibles:</span> <span class="stat-value" :class="{ 'cero': store.preguntasDisponibles === 0 }">{{ store.preguntasDisponibles }}</span></div>
</div>
</div>
<!-- Barra de Progreso -->
<div class="progress-container">
<div class="progress-info">
<span>Progreso de asignación</span>
<span>{{ porcentajeAsignado }}%</span>
</div>
<a-progress
:percent="porcentajeAsignado"
:stroke-color="store.limiteAlcanzado ? '#ff4d4f' : '#52c41a'"
:status="store.limiteAlcanzado ? 'exception' : 'normal'"
/>
<div v-if="store.limiteAlcanzado" class="alerta">
<a-alert type="warning" show-icon message="Se ha alcanzado el límite de preguntas" />
</div>
</div>
<!-- Acciones Rápidas -->
<div class="acciones-rapidas">
<a-button @click="autoDistribuir" :disabled="store.preguntasDisponibles === 0">
<template #icon><RobotOutlined /></template> Distribuir Automático
</a-button>
<a-button type="primary" @click="guardarTodo" :loading="store.guardando">
<template #icon><SaveOutlined /></template> Guardar Todo
</a-button>
<a-button @click="restablecerCambios" danger>
<template #icon><UndoOutlined /></template> Deshacer Cambios
</a-button>
</div>
</div>
<!-- Tabla de Reglas -->
<div class="reglas-table">
<a-table
:data-source="store.reglas"
:columns="columnasReglas"
:loading="store.cargando"
row-key="curso_id"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'orden'">
<div class="orden-controls">
<a-button size="small" @click="cambiarOrden(record, -1)" :disabled="esPrimeraRegla(record)"><UpOutlined /></a-button>
<a-input-number v-model:value="record.orden" :min="1" :max="store.reglas.length" @change="marcarComoModificado(record)" size="small" style="width: 60px; margin: 0 4px" />
<a-button size="small" @click="cambiarOrden(record, 1)" :disabled="esUltimaRegla(record)"><DownOutlined /></a-button>
</div>
</template>
<template v-if="column.key === 'curso'">
<div class="curso-info"><strong>{{ record.nombre }}</strong><small>ID: {{ record.curso_id }}</small></div>
</template>
<template v-if="column.key === 'preguntas'">
<div class="preguntas-control">
<a-input-number
v-model:value="record.cantidad_preguntas"
:min="0"
:max="maxPreguntasPorCurso(record)"
@change="validarPreguntas(record)"
style="width: 100px"
:name="'cantidad_' + record.curso_id"
/>
<div class="preguntas-info">
<small>Máx: {{ maxPreguntasPorCurso(record) }}</small>
<small v-if="tieneErrorPreguntas(record)" class="error"><ExclamationCircleOutlined /> Excede límite</small>
</div>
</div>
</template>
<template v-if="column.key === 'dificultad'">
<a-select v-model:value="record.nivel_dificultad" @change="marcarComoModificado(record)" style="width: 120px">
<a-select-option value="bajo"><a-tag color="green">Bajo</a-tag></a-select-option>
<a-select-option value="medio"><a-tag color="orange">Medio</a-tag></a-select-option>
<a-select-option value="alto"><a-tag color="red">Alto</a-tag></a-select-option>
</a-select>
</template>
<template v-if="column.key === 'ponderacion'">
<div class="ponderacion-control">
<a-input-number
v-model:value="record.ponderacion"
:min="0"
:max="100"
:step="0.1"
:precision="2"
string-mode
style="width: 120px"
@change="marcarComoModificado(record)"
/>
<span style="margin-left: 4px"></span>
</div>
</template>
<template v-if="column.key === 'acciones'">
<a-space>
<a-button type="primary" size="small" @click="guardarReglaIndividual(record)" :disabled="!reglaModificada(record) || store.guardando" :loading="guardandoIndividual[record.curso_id]"><SaveOutlined /></a-button>
<a-button danger size="small" @click="eliminarRegla(record)" v-if="record.regla_id" :disabled="store.guardando"><DeleteOutlined /></a-button>
</a-space>
</template>
<template v-if="column.key === 'estado'">
<a-tag v-if="reglaModificada(record)" color="orange"><EditOutlined /> Modificado</a-tag>
<a-tag v-else-if="record.regla_id" color="green"><CheckCircleOutlined /> Guardado</a-tag>
<a-tag v-else color="default"><ClockCircleOutlined /> Sin guardar</a-tag>
</template>
</template>
</a-table>
</div>
<!-- Resumen -->
<div class="resumen-section">
<a-card title="Resumen">
<div class="resumen-grid">
<div class="resumen-item"><span class="resumen-label">Cursos configurados:</span> <span class="resumen-value">{{ cursosConfigurados }} / {{ store.reglas.length }}</span></div>
<div class="resumen-item"><span class="resumen-label">Total preguntas asignadas:</span> <span class="resumen-value">{{ store.totalPreguntasAsignadas }}</span></div>
<div class="resumen-item"><span class="resumen-label">Preguntas disponibles:</span> <span class="resumen-value">{{ store.preguntasDisponibles }}</span></div>
<div class="resumen-item"><span class="resumen-label">Porcentaje completado:</span> <span class="resumen-value">{{ porcentajeAsignado }}%</span></div>
</div>
</a-card>
</div>
</div>
<!-- Modal de Confirmación -->
<a-modal
v-model:open="showConfirmModal"
title="Confirmación"
ok-text="Confirmar"
cancel-text="Cancelar"
@ok="confirmarAccion"
@cancel="cancelarAccion"
>
<p>{{ confirmMessage }}</p>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useReglaAreaProcesoStore } from '../../../store/reglaAreaProceso.store'
import { message } from 'ant-design-vue'
import {
ArrowLeftOutlined,
SaveOutlined,
DeleteOutlined,
EditOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
UpOutlined,
DownOutlined,
RobotOutlined,
UndoOutlined,
ReloadOutlined,
ExclamationCircleOutlined
} from '@ant-design/icons-vue'
const store = useReglaAreaProcesoStore()
// Estados
const filtroBusqueda = ref('')
const filtroProceso = ref(null)
const areasProceso = ref([]) // inicializado seguro
const areasProcesoFiltradas = ref([]) // inicializado seguro
const reglasOriginales = ref([]) // copia de reglas originales
const reglasModificadas = ref([]) // ids de reglas modificadas
const guardandoIndividual = ref({})
const showConfirmModal = ref(false)
const confirmMessage = ref('')
const confirmAction = ref(null)
const confirmData = ref(null)
const nombreAreaActual = ref('')
// Computed
const porcentajeAsignado = computed(() => {
return store.totalPreguntasProceso === 0
? 0
: Math.round((store.totalPreguntasAsignadas / store.totalPreguntasProceso) * 100)
})
const cursosConfigurados = computed(() => {
if (!Array.isArray(store.reglas)) return 0
return store.reglas.filter(r => r.cantidad_preguntas > 0).length
})
// Computed defensivo para procesos únicos
const procesosUnicos = computed(() => {
if (!Array.isArray(areasProceso.value)) return []
const procesos = areasProceso.value.map(ap => ({
id: ap.proceso_id,
nombre: ap.proceso_nombre
}))
// eliminar duplicados por id
return [...new Map(procesos.map(p => [p.id, p])).values()]
})
// Columnas
const columnasAreas = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: 'Área', dataIndex: 'area_nombre', key: 'area' },
{ title: 'Proceso', dataIndex: 'proceso_nombre', key: 'proceso' },
{ title: 'Cursos', key: 'cursos', width: 100, align: 'center' },
{ title: 'Reglas Configuradas', key: 'reglas_count', width: 120, align: 'center' },
{ title: 'Acción', key: 'accion', width: 100, align: 'center' }
]
const columnasReglas = [
{ title: 'Orden', key: 'orden', width: 120 },
{ title: 'Curso', key: 'curso' },
{ title: 'Preguntas', key: 'preguntas', width: 150 },
{ title: 'Dificultad', key: 'dificultad', width: 120 },
{ title: 'Ponderación', key: 'ponderacion', width: 200 },
{ title: 'Estado', key: 'estado', width: 100 },
{ title: 'Acciones', key: 'acciones', width: 120 }
]
// Métodos
const cargarAreasProceso = async () => {
await store.cargarAreaProcesos()
areasProceso.value = Array.isArray(store.areaProcesos) ? store.areaProcesos : []
areasProcesoFiltradas.value = [...areasProceso.value]
}
const filtrarAreas = () => {
let filtradas = Array.isArray(areasProceso.value) ? areasProceso.value : []
if (filtroProceso.value) {
filtradas = filtradas.filter(ap => ap.proceso_id === filtroProceso.value)
}
if (filtroBusqueda.value) {
const busqueda = filtroBusqueda.value.toLowerCase()
filtradas = filtradas.filter(ap =>
(ap.area_nombre || '').toLowerCase().includes(busqueda) ||
(ap.proceso_nombre || '').toLowerCase().includes(busqueda)
)
}
areasProcesoFiltradas.value = filtradas
}
const limpiarFiltros = () => {
filtroBusqueda.value = ''
filtroProceso.value = null
areasProcesoFiltradas.value = [...areasProceso.value]
}
const seleccionarAreaProceso = async (areaProceso) => {
if (!areaProceso?.id) return
await store.setAreaProceso(areaProceso.id)
reglasOriginales.value = Array.isArray(store.reglas) ? JSON.parse(JSON.stringify(store.reglas)) : []
reglasModificadas.value = []
nombreAreaActual.value = areaProceso?.area?.nombre || ''
}
const volverALista = () => {
if (reglasModificadas.value.length > 0) {
confirmMessage.value = 'Tienes cambios sin guardar. ¿Seguro que quieres salir?'
confirmAction.value = confirmarVolver
showConfirmModal.value = true
} else {
store.reset()
}
}
const confirmarVolver = () => {
store.reset()
showConfirmModal.value = false
}
const maxPreguntasPorCurso = (regla) => store.calcularPreguntasDisponibles(regla?.curso_id, regla?.cantidad_preguntas || 0)
const tieneErrorPreguntas = (regla) => regla?.cantidad_preguntas > maxPreguntasPorCurso(regla)
const validarPreguntas = (regla) => {
if (tieneErrorPreguntas(regla)) regla.cantidad_preguntas = maxPreguntasPorCurso(regla)
marcarComoModificado(regla)
}
const reglaModificada = (regla) => {
if (!Array.isArray(reglasOriginales.value)) return true
const original = reglasOriginales.value.find(r => r.curso_id === regla?.curso_id)
if (!original) return true
return original.orden !== regla.orden ||
original.cantidad_preguntas !== regla.cantidad_preguntas ||
original.nivel_dificultad !== regla.nivel_dificultad ||
original.ponderacion !== regla.ponderacion
}
const marcarComoModificado = (regla) => {
if (!regla?.curso_id) return
if (reglaModificada(regla) && !reglasModificadas.value.includes(regla.curso_id)) {
reglasModificadas.value.push(regla.curso_id)
}
}
const esPrimeraRegla = (regla) => store.reglas.findIndex(r => r?.curso_id === regla?.curso_id) === 0
const esUltimaRegla = (regla) => store.reglas.findIndex(r => r?.curso_id === regla?.curso_id) === (Array.isArray(store.reglas) ? store.reglas.length - 1 : 0)
// Funciones CRUD y acciones (defensivas)
const cambiarOrden = (regla, direccion) => {
if (!Array.isArray(store.reglas)) return
const index = store.reglas.findIndex(r => r?.curso_id === regla?.curso_id)
const nuevoIndex = index + direccion
if (nuevoIndex < 0 || nuevoIndex >= store.reglas.length) return
const temp = store.reglas[index]; store.reglas[index] = store.reglas[nuevoIndex]; store.reglas[nuevoIndex] = temp
const tempOrden = store.reglas[index].orden; store.reglas[index].orden = store.reglas[nuevoIndex].orden; store.reglas[nuevoIndex].orden = tempOrden
marcarComoModificado(store.reglas[index])
marcarComoModificado(store.reglas[nuevoIndex])
}
// Guardar todas las reglas modificadas
const guardarTodo = async () => {
if (!Array.isArray(reglasModificadas.value) || reglasModificadas.value.length === 0) {
message.info('No hay cambios para guardar')
return
}
const reglasAGuardar = store.reglas.filter(r => reglasModificadas.value.includes(r.curso_id))
const { success, error } = await store.guardarReglasMultiple(reglasAGuardar)
if (success) {
message.success('Reglas guardadas correctamente')
reglasModificadas.value = [] // reset
} else {
message.error(error || 'Error al guardar reglas')
}
}
const guardarReglaIndividual = async (regla) => {
if (!store) return
const result = await store.guardarRegla(regla)
if (!result.success) {
console.error('Error al guardar regla individual:', result.error)
}
}
// Restablecer cambios locales
const restablecerCambios = () => {
reglasModificadas.value = []
// Restaurar reglas a su estado original
if (Array.isArray(reglasOriginales.value)) {
store.reglas = JSON.parse(JSON.stringify(reglasOriginales.value))
}
message.info('Cambios deshechos')
}
const autoDistribuir = () => {
if (!Array.isArray(store.reglas) || store.reglas.length === 0) {
message.info('No hay reglas para distribuir')
return
}
// Total de preguntas disponibles
let disponibles = store.preguntasDisponibles
// Reglas que aún no tienen preguntas asignadas
const reglasSinPreguntas = store.reglas.filter(r => !r.cantidad_preguntas || r.cantidad_preguntas === 0)
if (reglasSinPreguntas.length === 0) {
message.info('Todos los cursos ya tienen preguntas asignadas')
return
}
// Distribuir de forma equitativa
const cantidadPorCurso = Math.floor(disponibles / reglasSinPreguntas.length)
reglasSinPreguntas.forEach(r => {
r.cantidad_preguntas = cantidadPorCurso
marcarComoModificado(r)
})
message.success('Preguntas distribuidas automáticamente')
}
// Más métodos de guardado y eliminación...
// (igual que tu código original, solo asegurando ?. y Array.isArray defensivo)
const confirmarAccion = () => { if(confirmAction.value) confirmAction.value() }
const cancelarAccion = () => {
showConfirmModal.value = false
confirmAction.value = null
confirmData.value = null
}
onMounted(() => { cargarAreasProceso() })
</script>
<style scoped>
.reglas-container {
padding: 0;
}
.reglas-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 0 8px;
}
.header-title h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f1f1f;
}
.subtitle {
margin: 4px 0 0 0;
color: #666;
font-size: 14px;
}
/* Selección */
.seleccion-section {
margin-top: 20px;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
margin-bottom: 24px;
}
.card-header {
padding: 20px 24px;
border-bottom: 1px solid #f0f0f0;
}
.card-header h3 {
margin: 0;
font-size: 18px;
color: #1f1f1f;
}
.card-header p {
margin: 4px 0 0 0;
color: #666;
font-size: 14px;
}
.card-body {
padding: 24px;
}
.filters {
display: flex;
align-items: center;
margin-bottom: 24px;
}
.clickable-table :deep(.ant-table-tbody > tr) {
cursor: pointer;
}
.clickable-table :deep(.ant-table-tbody > tr:hover) {
background: #fafafa;
}
/* Configuración */
.configuracion-section {
margin-top: 20px;
}
.proceso-info {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
padding: 24px;
margin-bottom: 24px;
}
.proceso-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.proceso-header h3 {
margin: 0;
font-size: 20px;
color: #1f1f1f;
}
.separator {
color: #999;
margin: 0 8px;
}
.proceso-stats {
display: flex;
gap: 24px;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.stat-value {
font-size: 18px;
font-weight: 600;
color: #1f1f1f;
}
.stat-value.cero {
color: #ff4d4f;
}
.progress-container {
margin-bottom: 20px;
}
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
color: #666;
}
.alerta {
margin-top: 12px;
}
.acciones-rapidas {
display: flex;
gap: 12px;
margin-top: 20px;
}
/* Tabla de Reglas */
.reglas-table {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
margin-bottom: 24px;
overflow: hidden;
}
.orden-controls {
display: flex;
align-items: center;
justify-content: center;
}
.curso-info {
display: flex;
flex-direction: column;
}
.curso-info small {
color: #999;
font-size: 12px;
}
.preguntas-control {
display: flex;
flex-direction: column;
gap: 4px;
}
.preguntas-info {
display: flex;
justify-content: space-between;
font-size: 12px;
}
.preguntas-info .error {
color: #ff4d4f;
display: flex;
align-items: center;
gap: 4px;
}
.ponderacion-control {
display: flex;
align-items: center;
gap: 8px;
}
/* Resumen */
.resumen-section {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
}
.resumen-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.resumen-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px;
background: #fafafa;
border-radius: 8px;
}
.resumen-label {
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.resumen-value {
font-size: 18px;
font-weight: 600;
color: #1f1f1f;
}
/* Responsive */
@media (max-width: 1200px) {
.resumen-grid {
grid-template-columns: repeat(2, 1fr);
}
.proceso-header {
flex-direction: column;
gap: 16px;
}
.proceso-stats {
width: 100%;
justify-content: space-between;
}
}
@media (max-width: 768px) {
.reglas-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.filters {
flex-direction: column;
gap: 12px;
}
.filters > * {
width: 100%;
margin-left: 0 !important;
}
.acciones-rapidas {
flex-direction: column;
}
.resumen-grid {
grid-template-columns: 1fr;
}
.reglas-table {
overflow-x: auto;
}
.reglas-table :deep(.ant-table) {
min-width: 800px;
}
}
</style>

@ -122,7 +122,7 @@
@click="showProcessModal(record)"
class="action-btn"
>
<ApartmentOutlined /> Procesos
<ApartmentOutlined /> P
</a-button>
@ -132,7 +132,7 @@
@click="showCourseModal(record)"
class="action-btn"
>
<BookOutlined /> Cursos
<BookOutlined /> C
</a-button>
<a-button
type="link"
@ -140,7 +140,7 @@
@click="showEditModal(record)"
class="action-btn"
>
<EditOutlined /> Editar
<EditOutlined />
</a-button>
<a-button
type="link"
@ -149,7 +149,7 @@
@click="confirmDelete(record)"
class="action-btn"
>
<DeleteOutlined /> Eliminar
<DeleteOutlined />
</a-button>
</a-space>
</template>

@ -193,6 +193,12 @@
<span class="menu-label">Cursos</span>
</div>
</a-menu-item>
<a-menu-item key="examenes-reglas-lista" class="menu-item">
<div class="menu-item-content">
<UnorderedListOutlined class="menu-icon" />
<span class="menu-label">Reglas</span>
</div>
</a-menu-item>
</a-sub-menu>
<a-sub-menu key="preguntas" class="sub-menu">
@ -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'
}
}

@ -0,0 +1,355 @@
<template>
<a-card title="Mi Examen" :loading="examenStore.cargando">
<!-- Estado de carga -->
<template v-if="examenStore.cargando && !examenStore.examenActual">
<a-skeleton active />
</template>
<!-- Si no hay examen asignado -->
<div v-else-if="!examenStore.examenActual" class="no-examen">
<a-empty description="No tienes un examen asignado actualmente">
<template #extra>
<a-button type="primary" @click="showModal = true">
Asignar Examen
</a-button>
</template>
</a-empty>
</div>
<!-- Si hay examen -->
<div v-else class="examen-info">
<a-descriptions title="Información del Examen" bordered>
<a-descriptions-item label="Proceso">
{{ examenStore.examenActual.proceso?.nombre || 'No asignado' }}
</a-descriptions-item>
<a-descriptions-item label="Área">
{{ examenStore.examenActual.area?.nombre || 'No asignado' }}
</a-descriptions-item>
<a-descriptions-item label="Intentos realizados">
{{ examenStore.examenActual.intentos || 0 }}
</a-descriptions-item>
<a-descriptions-item label="Estado del examen">
<a-tag :color="getEstadoColor">
{{ getEstadoTexto }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="Pago" v-if="examenStore.examenActual.pagado">
<a-tag color="green">Pagado ({{ examenStore.examenActual.tipo_pago }})</a-tag>
</a-descriptions-item>
</a-descriptions>
<div class="actions-container" style="margin-top: 24px;">
<!-- Botón Seleccionar Área (solo si no tiene área) -->
<a-button
v-if="!examenStore.examenActual.area"
type="primary"
@click="showModal = true"
style="margin-right: 8px;"
>
Seleccionar Área
</a-button>
<!-- Botón Iniciar Examen (solo si hay examen y no hay intentos) -->
<a-button
v-if="examenStore.examenActual && (!examenStore.examenActual.intentos || examenStore.examenActual.intentos === 0)"
type="primary"
:loading="iniciandoExamen"
@click="irAlExamen"
style="margin-right: 8px;"
>
Iniciar Examen
</a-button>
<!-- Botón Ver Resultados (solo si hay intentos) -->
<a-button
v-if="examenStore.examenActual?.intentos > 0"
type="default"
@click="verResultado"
>
Ver Resultados
</a-button>
</div>
</div>
<!-- Modal para seleccionar área -->
<a-modal
v-model:visible="showModal"
title="Seleccionar Área de Examen"
:confirm-loading="creandoExamen"
@ok="crearExamen"
@cancel="resetModal"
:mask-closable="false"
width="600px"
>
<a-form :model="formState" :rules="rules" layout="vertical">
<!-- Selección de Proceso -->
<a-form-item label="Proceso" name="proceso_id" required>
<a-select
v-model:value="formState.proceso_id"
placeholder="Seleccione un proceso"
:options="procesoOptions"
@change="handleProcesoChange"
:loading="examenStore.cargando"
/>
</a-form-item>
<!-- Selección de Área -->
<a-form-item label="Área" name="area_proceso_id" required>
<a-select
v-model:value="formState.area_proceso_id"
placeholder="Seleccione un área"
:options="areaOptions"
:disabled="!formState.proceso_id"
:loading="examenStore.cargando"
/>
</a-form-item>
<!-- Información de pago (si el proceso lo requiere) -->
<div v-if="procesoRequierePago">
<a-alert
message="Este proceso requiere pago"
type="info"
show-icon
style="margin-bottom: 16px;"
/>
<a-form-item label="Tipo de Pago" name="tipo_pago" required>
<a-select
v-model:value="formState.tipo_pago"
placeholder="Seleccione tipo de pago"
:options="tipoPagoOptions"
/>
</a-form-item>
<a-form-item label="Código de Pago" name="codigo_pago" required>
<a-input
v-model:value="formState.codigo_pago"
placeholder="Ingrese el código de pago"
/>
</a-form-item>
</div>
</a-form>
</a-modal>
</a-card>
</template>
<script setup>
import { ref, computed, reactive, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useExamenStore } from '../../store/examen.store'
import { message, Modal } from 'ant-design-vue'
const router = useRouter()
const examenStore = useExamenStore()
// Estados reactivos
const showModal = ref(false)
const iniciandoExamen = ref(false)
const creandoExamen = ref(false)
// Formulario para crear examen
const formState = reactive({
proceso_id: undefined,
area_proceso_id: undefined,
tipo_pago: undefined,
codigo_pago: ''
})
// Reglas de validación
const rules = {
proceso_id: [{ required: true, message: 'Por favor seleccione un proceso' }],
area_proceso_id: [{ required: true, message: 'Por favor seleccione un área' }],
tipo_pago: [{
required: computed(() => procesoRequierePago.value),
message: 'Por favor seleccione tipo de pago'
}],
codigo_pago: [{
required: computed(() => procesoRequierePago.value),
message: 'Por favor ingrese código de pago'
}]
}
// Opciones para selects
const procesoOptions = computed(() => {
return examenStore.procesos.map(p => ({
value: p.id,
label: p.nombre,
requiere_pago: p.requiere_pago
}))
})
const areaOptions = computed(() => {
return examenStore.areas.map(a => ({
value: a.area_proceso_id,
label: a.nombre
}))
})
const tipoPagoOptions = [
{ value: 'pyto_peru', label: 'Pago por Pytoperú' },
{ value: 'banco_nacion', label: 'Banco de la Nación' },
{ value: 'caja', label: 'Caja' }
]
// Computed properties
const procesoRequierePago = computed(() => {
const proceso = examenStore.procesos.find(p => p.id === formState.proceso_id)
return proceso?.requiere_pago === 1
})
const getEstadoColor = computed(() => {
if (!examenStore.examenActual?.intentos || examenStore.examenActual.intentos === 0) {
return 'blue'
}
return 'green'
})
const getEstadoTexto = computed(() => {
if (!examenStore.examenActual?.intentos || examenStore.examenActual.intentos === 0) {
return 'Disponible'
}
return 'Completado'
})
// Métodos
const handleProcesoChange = async (procesoId) => {
formState.area_proceso_id = undefined
if (procesoId) {
await examenStore.fetchAreas(procesoId)
}
}
const crearExamen = async () => {
try {
creandoExamen.value = true
const pagoData = procesoRequierePago.value ? {
tipo_pago: formState.tipo_pago,
codigo_pago: formState.codigo_pago
} : null
const result = await examenStore.crearExamen(
formState.area_proceso_id,
pagoData
)
if (result.success) {
message.success('Examen creado correctamente')
showModal.value = false
resetModal()
// Recargar el examen actual
await examenStore.fetchExamenActual()
} else {
message.error(result.message || 'Error al crear examen')
}
} catch (error) {
message.error('Error al crear examen')
} finally {
creandoExamen.value = false
}
}
const irAlExamen = async () => {
if (!examenStore.examenActual?.id) {
message.error('No hay examen para iniciar')
return
}
try {
iniciandoExamen.value = true
// Primero intentar generar preguntas
const generarResult = await examenStore.generarPreguntas(examenStore.examenActual.id)
if (!generarResult.success) {
// Si hay error real, mostrar mensaje
if (!generarResult.ya_tiene_preguntas) {
message.error(generarResult.message || 'Error al generar preguntas')
return
}
// Si ya tiene preguntas, es un éxito (continuar)
}
// Luego iniciar el examen (esto carga las preguntas)
const iniciarResult = await examenStore.iniciarExamen(examenStore.examenActual.id)
if (iniciarResult.success) {
// Redirigir al panel de examen
router.push({ name: 'PanelExamen', params: { examenId: examenStore.examenActual.id } })
} else {
message.error(iniciarResult.message || 'Error al iniciar examen')
}
} catch (error) {
message.error('Error al procesar la solicitud')
console.error(error)
} finally {
iniciandoExamen.value = false
}
}
const verResultado = async () => {
// Actualizar intentos del examen
await examenStore.fetchExamenActual()
// Redirigir a resultados
router.push({
name: 'PanelResultados',
params: { examenId: examenStore.examenActual?.id }
})
}
const resetModal = () => {
Object.keys(formState).forEach(key => {
if (key === 'proceso_id' || key === 'area_proceso_id') {
formState[key] = undefined
} else if (key === 'codigo_pago') {
formState[key] = ''
} else {
formState[key] = undefined
}
})
}
// Lifecycle
onMounted(async () => {
// Cargar procesos disponibles
await examenStore.fetchProcesos()
// Cargar examen actual
await examenStore.fetchExamenActual()
})
// Watch para limpiar áreas cuando se cierra el modal
watch(showModal, (newVal) => {
if (!newVal) {
examenStore.areas = []
}
})
</script>
<style scoped>
.no-examen {
text-align: center;
padding: 40px 0;
}
.examen-info {
padding: 16px 0;
}
.actions-container {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
:deep(.ant-descriptions) {
margin-bottom: 16px;
}
:deep(.ant-descriptions-item-label) {
font-weight: 600;
width: 180px;
}
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,128 @@
<template>
<a-card class="pagos-card">
<template #title>
<span>Mis Pagos Realizados</span>
</template>
<!-- Loading -->
<a-spin :spinning="loading">
<!-- Resumen -->
<a-alert
v-if="!loading"
type="info"
show-icon
class="mb-3"
>
Total de pagos encontrados: <strong>{{ pagos.length }}</strong>
</a-alert>
<!-- Tabla -->
<a-table
:dataSource="pagos"
:columns="columns"
rowKey="key"
bordered
:pagination="{ pageSize: 5 }"
>
<!-- Tipo -->
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'tipo'">
<a-tag :color="getColor(record.tipo)">
{{ record.tipo.toUpperCase() }}
</a-tag>
</template>
<template v-else-if="column.key === 'monto'">
S/ {{ Number(record.monto).toFixed(2) }}
</template>
<template v-else-if="column.key === 'fecha_pago'">
{{ formatFecha(record.fecha_pago) }}
</template>
</template>
</a-table>
</a-spin>
</a-card>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import api from '.././../axiosPostulante'
const pagos = ref([])
const loading = ref(false)
const columns = [
{
title: 'Tipo',
dataIndex: 'tipo',
key: 'tipo'
},
{
title: 'Código',
dataIndex: 'codigo',
key: 'codigo'
},
{
title: 'Monto',
dataIndex: 'monto',
key: 'monto'
},
{
title: 'Fecha de Pago',
dataIndex: 'fecha_pago',
key: 'fecha_pago'
}
]
const obtenerPagos = async () => {
loading.value = true
try {
const { data } = await api.get('/postulante/pagos')
if (data.success) {
pagos.value = data.pagos.map((pago, index) => ({
...pago,
key: index
}))
} else {
message.error('No se pudieron obtener los pagos')
}
} catch (error) {
console.error(error)
message.error('Error al cargar pagos')
} finally {
loading.value = false
}
}
const getColor = (tipo) => {
if (tipo === 'pyto_peru') return 'blue'
if (tipo === 'banco_nacion') return 'green'
if (tipo === 'caja') return 'orange'
return 'default'
}
const formatFecha = (fecha) => {
if (!fecha) return '-'
return new Date(fecha).toLocaleString('es-PE')
}
onMounted(() => {
obtenerPagos()
})
</script>
<style scoped>
.pagos-card {
max-width: 1000px;
margin: 20px auto;
}
.mb-3 {
margin-bottom: 16px;
}
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,664 @@
<template>
<div class="examen-panel">
<!-- Header con información del examen -->
<a-card>
<div class="examen-header">
<div class="header-info">
<h2>{{ examenInfo.proceso || 'Proceso no disponible' }}</h2>
<p><strong>Área:</strong> {{ examenInfo.area || 'Área no disponible' }}</p>
<p><strong>Duración:</strong> {{ examenInfo.duracion }} minutos</p>
<p><strong>Intentos:</strong> {{ examenInfo.intentos }} / {{ examenInfo.intentos_maximos }}</p>
<p><strong>Tiempo restante:</strong></p>
</div>
<div class="timer">
<a-statistic-countdown
:value="timerValue"
@finish="finalizarExamenAutomaticamente"
format="HH:mm:ss"
/>
</div>
</div>
</a-card>
<!-- Preguntas -->
<a-card
v-for="(pregunta, index) in preguntasTransformadas"
:key="pregunta.id"
class="pregunta-card"
style="margin-top: 16px;"
>
<template #title>
<div class="pregunta-header">
<span class="pregunta-numero">Pregunta {{ index + 1 }}</span>
<span class="curso-tag">
<a-tag color="blue">{{ pregunta.curso }}</a-tag>
</span>
<a-tag :color="pregunta.respondida ? 'green' : 'orange'">
{{ pregunta.estado === 'respondida' ? 'Respondida' : 'Pendiente' }}
</a-tag>
</div>
</template>
<!-- Enunciado -->
<div class="enunciado" v-html="pregunta.enunciado"></div>
<!-- Contenido adicional -->
<div v-if="pregunta.extra && pregunta.extra !== pregunta.enunciado"
class="extra" v-html="pregunta.extra"></div>
<!-- Opciones múltiples -->
<div class="opciones" v-if="pregunta.opciones && pregunta.opciones.length">
<a-radio-group
v-model:value="pregunta.respuestaSeleccionada"
@change="responderPregunta(pregunta)"
:disabled="pregunta.estado === 'respondida'"
>
<a-space direction="vertical" style="width: 100%;">
<a-radio
v-for="opcion in pregunta.opcionesOrdenadas"
:key="opcion.key"
:value="opcion.key.toString()" <!-- CONVERTIR A STRING -->
class="opcion-radio"
>
<span class="opcion-key">{{ getLetraOpcion(opcion.key) }}.</span>
<span v-html="opcion.texto" class="opcion-texto"></span>
</a-radio>
</a-space>
</a-radio-group>
<!-- Mostrar respuesta seleccionada -->
<div v-if="pregunta.respuestaSeleccionada" class="seleccion-actual">
<a-alert
:message="`Ha seleccionado: ${getTextoOpcionSeleccionada(pregunta)}`"
type="info"
show-icon
style="margin-top: 12px;"
/>
</div>
</div>
<!-- Pregunta abierta (si no hay opciones) -->
<div v-else class="pregunta-abierta">
<a-textarea
v-model:value="pregunta.respuestaTexto"
placeholder="Escriba su respuesta aquí..."
:rows="4"
:disabled="pregunta.estado === 'respondida'"
@blur="responderPreguntaTexto(pregunta)"
/>
</div>
<!-- Información de respuesta correcta (solo para debug) -->
<div v-if="debugMode" class="debug-info">
<a-alert
:message="`Respuesta correcta: ${pregunta.respuesta} (key: ${pregunta.respuestaKey})`"
type="warning"
show-icon
style="margin-top: 12px;"
/>
</div>
</a-card>
<!-- Resumen y botones -->
<a-card style="margin-top: 24px;">
<div class="resumen-examen">
<h3>Resumen del Examen</h3>
<p><strong>Total preguntas:</strong> {{ preguntasTransformadas.length }}</p>
<p><strong>Respondidas:</strong> {{ preguntasRespondidas }} de {{ preguntasTransformadas.length }}</p>
<p><strong>Progreso:</strong></p>
<a-progress :percent="porcentajeCompletado" :stroke-color="{
'0%': '#108ee9',
'100%': '#87d068',
}" />
</div>
<div class="action-buttons" style="margin-top: 24px; text-align: center;">
<a-button
type="primary"
size="large"
:loading="finalizando"
@click="finalizarExamen"
:disabled="!todasRespondidas"
>
{{ todasRespondidas ? 'Finalizar Examen' : `Responda todas las preguntas (${preguntasTransformadas.length - preguntasRespondidas} pendientes)` }}
</a-button>
<a-button
type="default"
size="large"
style="margin-left: 12px;"
@click="guardarYSalir"
>
Guardar y salir
</a-button>
</div>
</a-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useExamenStore } from '../../store/examen.store'
import { message, Modal } from 'ant-design-vue'
const route = useRoute()
const router = useRouter()
const examenStore = useExamenStore()
const finalizando = ref(false)
const debugMode = ref(false) // Cambiar a true para depuración
const timerValue = ref(null)
// Computed properties
const examenInfo = computed(() => {
if (!examenStore.examenActual) {
return {
proceso: null,
area: null,
duracion: 60,
intentos: 0,
intentos_maximos: 3
}
}
return {
proceso: examenStore.examenActual?.proceso || 'Proceso no disponible',
area: examenStore.examenActual?.area || 'Área no disponible',
duracion: examenStore.examenActual?.duracion || 60,
intentos: examenStore.examenActual?.intentos || 0,
intentos_maximos: examenStore.examenActual?.intentos_maximos || 3
}
})
// Transformar preguntas - CORREGIDO para manejar keys numéricas
const preguntasTransformadas = computed(() => {
if (!examenStore.preguntas || !Array.isArray(examenStore.preguntas)) {
return []
}
return examenStore.preguntas.map(pregunta => {
// Encontrar la key correcta para la respuesta
let respuestaKey = null
if (pregunta.opciones && pregunta.respuesta) {
const opcionCorrecta = pregunta.opciones.find(op =>
op.texto === pregunta.respuesta ||
op.key.toString() === pregunta.respuesta.toString()
)
respuestaKey = opcionCorrecta ? opcionCorrecta.key : null
}
// Ordenar opciones por key
const opcionesOrdenadas = pregunta.opciones ?
[...pregunta.opciones].sort((a, b) => a.key - b.key) :
[]
return {
...pregunta,
respuestaKey, // Guardar la key de la respuesta correcta
opcionesOrdenadas,
// Agregar propiedades reactivas
respuestaSeleccionada: null,
respuestaTexto: '',
// El estado real viene del backend: 'pendiente' o 'respondida'
}
})
})
// Helper para convertir key numérico a letra
const getLetraOpcion = (key) => {
const letras = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
return letras[key] || `Opción ${key}`
}
// Obtener texto de opción seleccionada
const getTextoOpcionSeleccionada = (pregunta) => {
if (!pregunta.respuestaSeleccionada) return ''
const opcion = pregunta.opcionesOrdenadas.find(
op => op.key.toString() === pregunta.respuestaSeleccionada.toString()
)
return opcion ? opcion.texto : 'Opción no encontrada'
}
const preguntasRespondidas = computed(() => {
return preguntasTransformadas.value.filter(p =>
p.estado === 'respondida' || p.respuestaSeleccionada || p.respuestaTexto
).length
})
const porcentajeCompletado = computed(() => {
if (preguntasTransformadas.length === 0) return 0
return Math.round((preguntasRespondidas.value / preguntasTransformadas.value.length) * 100)
})
const todasRespondidas = computed(() => {
return preguntasTransformadas.value.every(p =>
p.estado === 'respondida' || p.respuestaSeleccionada || p.respuestaTexto
)
})
// Métodos
const responderPregunta = async (pregunta) => {
if (!pregunta.respuestaSeleccionada) return
try {
// Asegurarse de que enviamos string (no número)
const respuestaString = pregunta.respuestaSeleccionada.toString()
// Encontrar el texto completo de la opción seleccionada
const opcionSeleccionada = pregunta.opcionesOrdenadas.find(
op => op.key.toString() === respuestaString
)
// Enviar el TEXTO de la opción, no la key
const textoRespuesta = opcionSeleccionada ? opcionSeleccionada.texto : respuestaString
const result = await examenStore.responderPregunta(
pregunta.id, // ID de PreguntaAsignada
textoRespuesta // Enviar el texto, no la key
)
if (result.success) {
// Actualizar estado local
pregunta.estado = 'respondida'
message.success('Respuesta guardada correctamente')
// Verificar si es correcta
if (result.correcta) {
message.info('¡Respuesta correcta!')
} else {
message.warning('Respuesta incorrecta')
}
} else {
message.error(result.message || 'Error al guardar respuesta')
// Revertir selección si falla
pregunta.respuestaSeleccionada = null
}
} catch (error) {
message.error('Error al guardar respuesta')
console.error('Error:', error)
pregunta.respuestaSeleccionada = null
}
}
const responderPreguntaTexto = async (pregunta) => {
if (!pregunta.respuestaTexto.trim()) return
try {
const result = await examenStore.responderPregunta(
pregunta.id,
pregunta.respuestaTexto
)
if (result.success) {
pregunta.estado = 'respondida'
message.success('Respuesta guardada')
} else {
message.error(result.message || 'Error al guardar respuesta')
}
} catch (error) {
message.error('Error al guardar respuesta')
console.error(error)
}
}
const finalizarExamen = async () => {
if (!todasRespondidas.value) {
message.warning(`Por favor responda todas las preguntas. ${preguntasTransformadas.value.length - preguntasRespondidas.value} pendientes.`)
return
}
Modal.confirm({
title: '¿Está seguro de finalizar el examen?',
content: 'Una vez finalizado no podrá modificar sus respuestas.',
okText: 'Sí, finalizar',
cancelText: 'Cancelar',
onOk: async () => {
try {
finalizando.value = true
const result = await examenStore.finalizarExamen(route.params.examenId)
if (result.success) {
message.success('Examen finalizado correctamente')
router.push({
name: 'panel-resultados',
params: { examenId: route.params.examenId }
})
} else {
message.error(result.message || 'Error al finalizar examen')
}
} catch (error) {
message.error('Error al finalizar examen')
console.error(error)
} finally {
finalizando.value = false
}
}
})
}
const guardarYSalir = async () => {
// Primero guardar todas las respuestas pendientes
const preguntasPendientes = preguntasTransformadas.value.filter(p =>
(p.respuestaSeleccionada || p.respuestaTexto) && p.estado !== 'respondida'
)
if (preguntasPendientes.length > 0) {
Modal.confirm({
title: 'Guardar respuestas pendientes',
content: `Tiene ${preguntasPendientes.length} respuesta(s) pendientes de guardar. ¿Desea guardarlas antes de salir?`,
okText: 'Guardar y salir',
cancelText: 'Salir sin guardar',
onOk: async () => {
try {
// Guardar cada respuesta pendiente
for (const pregunta of preguntasPendientes) {
if (pregunta.respuestaSeleccionada) {
await responderPregunta(pregunta)
} else if (pregunta.respuestaTexto) {
await responderPreguntaTexto(pregunta)
}
}
message.success('Respuestas guardadas')
router.push({ name: 'DashboardPostulante' })
} catch (error) {
message.error('Error al guardar respuestas')
}
},
onCancel: () => {
router.push({ name: 'DashboardPostulante' })
}
})
} else {
router.push({ name: 'DashboardPostulante' })
}
}
const finalizarExamenAutomaticamente = () => {
message.warning('Tiempo agotado. El examen se finalizará automáticamente.')
finalizarExamen()
}
const calcularTiempoRestante = () => {
if (examenStore.examenActual?.hora_inicio && examenInfo.value.duracion) {
const horaInicio = new Date(examenStore.examenActual.hora_inicio)
const duracionMs = examenInfo.value.duracion * 60 * 1000
const tiempoFinal = horaInicio.getTime() + duracionMs
timerValue.value = tiempoFinal
// Verificar si ya se agotó el tiempo
if (Date.now() > tiempoFinal) {
finalizarExamenAutomaticamente()
}
} else {
// Si no hay hora_inicio, usar duración por defecto desde ahora
timerValue.value = Date.now() + (examenInfo.value.duracion * 60 * 1000)
}
}
// Lifecycle
onMounted(async () => {
console.log('=== DATOS DEL EXAMEN ===')
try {
await examenStore.iniciarExamen(route.params.examenId)
// ahora las preguntas deberían estar disponibles
if (!examenStore.preguntas || examenStore.preguntas.length === 0) {
message.error('No se encontraron preguntas para este examen')
router.push({ name: 'DashboardPostulante' })
return
}
calcularTiempoRestante()
setInterval(calcularTiempoRestante, 30000)
} catch (err) {
console.error(err)
message.error('Error al cargar el examen')
router.push({ name: 'DashboardPostulante' })
}
})
</script>
<style scoped>
/* Estilos mejorados */
.examen-panel {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.examen-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
padding: 16px;
background: #f0f9ff;
border-radius: 8px;
border: 1px solid #d6e4ff;
}
.header-info h2 {
margin: 0 0 8px 0;
color: #1890ff;
font-size: 20px;
}
.header-info p {
margin: 4px 0;
color: #595959;
}
.timer {
text-align: right;
min-width: 180px;
}
.timer :deep(.ant-statistic-content) {
font-size: 28px;
font-weight: bold;
color: #fa541c;
}
.pregunta-card {
margin-bottom: 24px;
border: 1px solid #e8e8e8;
border-radius: 8px;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.3s;
}
.pregunta-card:hover {
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.12);
}
.pregunta-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.pregunta-numero {
font-weight: bold;
font-size: 18px;
color: #1890ff;
}
.curso-tag {
flex-grow: 1;
margin-left: 12px;
}
.enunciado {
font-size: 16px;
line-height: 1.7;
margin: 20px 0;
padding: 20px;
background: #fafafa;
border-radius: 8px;
border-left: 4px solid #1890ff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.enunciado :deep(math) {
font-size: 1.1em;
}
.extra {
font-size: 14px;
line-height: 1.6;
margin: 16px 0;
padding: 16px;
background: #fff7e6;
border-radius: 6px;
border: 1px solid #ffd591;
color: #d46b08;
}
.opciones {
margin-top: 24px;
}
.opcion-radio {
display: flex;
align-items: center;
padding: 16px 20px;
margin: 10px 0;
border: 2px solid #d9d9d9;
border-radius: 8px;
transition: all 0.3s;
min-height: 60px;
cursor: pointer;
}
.opcion-radio:hover {
border-color: #40a9ff;
background: #f0f9ff;
transform: translateY(-2px);
}
.opcion-radio:deep(.ant-radio) {
margin-right: 12px;
}
.opcion-key {
font-weight: bold;
color: #1890ff;
margin-right: 12px;
min-width: 24px;
font-size: 16px;
}
.opcion-texto {
flex: 1;
line-height: 1.6;
font-size: 15px;
}
.seleccion-actual {
margin-top: 16px;
}
.debug-info {
margin-top: 16px;
}
.pregunta-abierta textarea {
margin-top: 16px;
font-size: 15px;
line-height: 1.6;
}
.resumen-examen {
padding: 16px;
}
.resumen-examen h3 {
margin-bottom: 16px;
color: #1890ff;
}
.resumen-examen p {
margin: 8px 0;
color: #595959;
}
.action-buttons {
padding: 24px 0;
border-top: 1px solid #f0f0f0;
margin-top: 32px;
display: flex;
justify-content: center;
gap: 12px;
}
:deep(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
:deep(.ant-card-body) {
padding: 24px;
}
/* Estilos para matemáticas */
.enunciado :deep(.katex) {
font-size: 1.1em;
}
.extra :deep(.katex) {
font-size: 1em;
}
/* Responsive */
@media (max-width: 768px) {
.examen-panel {
padding: 10px;
}
.examen-header {
flex-direction: column;
align-items: stretch;
}
.timer {
text-align: left;
margin-top: 16px;
}
.pregunta-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.curso-tag {
margin-left: 0;
}
.enunciado {
padding: 16px;
font-size: 15px;
}
.opcion-radio {
padding: 12px 16px;
}
.action-buttons {
flex-direction: column;
gap: 16px;
}
.action-buttons button {
width: 100%;
}
}
</style>

@ -0,0 +1,827 @@
<template>
<div class="resultados-container">
<!-- Header de resultados -->
<a-card class="resultados-header">
<a-result
:status="resultadoStatus"
:title="resultadoTitulo"
:sub-title="resultadoSubtitulo"
>
<template #extra>
<a-space size="large">
<a-button type="primary" @click="verDetalles">
<eye-outlined />
Ver detalles del examen
</a-button>
<a-button @click="volverADashboard">
<home-outlined />
Volver al inicio
</a-button>
</a-space>
</template>
</a-result>
</a-card>
<!-- Estadísticas principales -->
<div class="stats-grid">
<a-card class="stat-card">
<div class="stat-content">
<check-circle-outlined class="stat-icon" style="color: #52c41a;" />
<div class="stat-info">
<div class="stat-value">{{ examenStore.progreso.correctas }}</div>
<div class="stat-label">Respuestas correctas</div>
</div>
</div>
</a-card>
<a-card class="stat-card">
<div class="stat-content">
<close-circle-outlined class="stat-icon" style="color: #f5222d;" />
<div class="stat-info">
<div class="stat-value">{{ respuestasIncorrectas }}</div>
<div class="stat-label">Respuestas incorrectas</div>
</div>
</div>
</a-card>
<a-card class="stat-card">
<div class="stat-content">
<star-outlined class="stat-icon" style="color: #faad14;" />
<div class="stat-info">
<div class="stat-value">{{ examenStore.progreso.puntaje_total }}</div>
<div class="stat-label">Puntaje total</div>
</div>
</div>
</a-card>
<a-card class="stat-card">
<div class="stat-content">
<percentage-outlined class="stat-icon" style="color: #1890ff;" />
<div class="stat-info">
<div class="stat-value">{{ porcentajeCorrectas }}%</div>
<div class="stat-label">Porcentaje de acierto</div>
</div>
</div>
</a-card>
</div>
<!-- Progreso por áreas/temas -->
<a-card class="progreso-card" title="Progreso por área">
<a-progress
:percent="porcentajeCorrectas"
:stroke-color="{
'0%': '#f5222d',
'100%': '#52c41a',
}"
:show-info="false"
/>
<div class="progreso-labels">
<span>0%</span>
<span>50%</span>
<span>100%</span>
</div>
<div class="estado-final">
<a-tag :color="getEstadoColor()" size="large">
{{ getEstadoTexto() }}
</a-tag>
<span class="recomendacion">
{{ recomendacionTexto }}
</span>
</div>
</a-card>
<!-- Detalle de preguntas -->
<a-card class="preguntas-detalle-card" title="Detalle de respuestas">
<a-collapse v-model:activeKey="activeKeys" accordion>
<a-collapse-panel
v-for="pregunta in examenStore.preguntas"
:key="pregunta.id"
:header="`Pregunta ${pregunta.orden}: ${getResumenPregunta(pregunta)}`"
>
<!-- Enunciado -->
<div class="pregunta-enunciado">
<h4>Enunciado:</h4>
<div v-html="formatearEnunciado(pregunta.enunciado || '')"></div>
<!-- Imágenes -->
<div v-if="pregunta.imagenes && pregunta.imagenes.length > 0" class="pregunta-images">
<a-image
v-for="(imagen, idx) in pregunta.imagenes"
:key="idx"
:src="imagen.url"
:alt="`Imagen ${idx + 1}`"
class="detalle-image"
:preview="true"
/>
</div>
</div>
<!-- Opciones -->
<div class="pregunta-opciones">
<h4>Opciones:</h4>
<div
v-for="opcion in pregunta.opciones || []"
:key="opcion.key"
class="opcion-detalle"
:class="{
'correcta': esOpcionCorrecta(pregunta, opcion.key),
'seleccionada': pregunta.respuesta_usuario == opcion.key,
'incorrecta-seleccionada': pregunta.respuesta_usuario == opcion.key && !esOpcionCorrecta(pregunta, opcion.key)
}"
>
<div class="opcion-detalle-content">
<div class="opcion-detalle-letra">
{{ obtenerLetraOpcion(opcion.key) }}
<span v-if="esOpcionCorrecta(pregunta, opcion.key)" class="correct-badge">
</span>
<span v-if="pregunta.respuesta_usuario == opcion.key && !esOpcionCorrecta(pregunta, opcion.key)"
class="incorrect-badge">
</span>
</div>
<div class="opcion-detalle-texto">
{{ opcion.texto }}
</div>
</div>
</div>
</div>
<!-- Resultado de la pregunta -->
<div class="pregunta-resultado">
<a-alert
:type="pregunta.es_correcta ? 'success' : 'error'"
:message="pregunta.es_correcta ? '¡Respuesta correcta!' : 'Respuesta incorrecta'"
:description="getDescripcionResultado(pregunta)"
show-icon
/>
</div>
<!-- Explicación (si existe) -->
<div v-if="pregunta.explicacion" class="pregunta-explicacion">
<h4>Explicación:</h4>
<p>{{ pregunta.explicacion }}</p>
</div>
</a-collapse-panel>
</a-collapse>
</a-card>
<!-- Recomendaciones -->
<a-card class="recomendaciones-card" title="Recomendaciones">
<a-list :data-source="recomendaciones" bordered>
<template #renderItem="{ item, index }">
<a-list-item>
<a-list-item-meta>
<template #title>
<a-tag :color="item.tipo === 'fortaleza' ? 'green' : 'orange'">
{{ item.tipo === 'fortaleza' ? 'Fortaleza' : 'Área a mejorar' }}
</a-tag>
{{ item.titulo }}
</template>
<template #description>
{{ item.descripcion }}
</template>
</a-list-item-meta>
<div v-if="item.accion">
<a-button type="link" size="small">
{{ item.accion }}
</a-button>
</div>
</a-list-item>
</template>
</a-list>
</a-card>
<!-- Acciones finales -->
<a-card class="acciones-card">
<div class="acciones-content">
<a-space size="large">
<a-button type="primary" size="large" @click="crearNuevoExamen">
<plus-outlined />
Realizar nuevo examen
</a-button>
<a-button size="large" @click="imprimirResultados">
<printer-outlined />
Imprimir resultados
</a-button>
<a-button size="large" @click="compartirResultados">
<share-alt-outlined />
Compartir resultados
</a-button>
</a-space>
</div>
</a-card>
<!-- Modal para ver detalles completos -->
<a-modal
v-model:open="modalDetallesVisible"
title="Detalles completos del examen"
width="800px"
:footer="null"
>
<div class="detalles-completos">
<div class="detalle-item">
<strong>Proceso:</strong> {{ examenStore.examenActual?.proceso?.nombre || 'No especificado' }}
</div>
<div class="detalle-item">
<strong>Área:</strong> {{ examenStore.examenActual?.area?.nombre || 'No especificado' }}
</div>
<div class="detalle-item">
<strong>Fecha de inicio:</strong> {{ formatFecha(examenStore.examenActual?.hora_inicio) }}
</div>
<div class="detalle-item">
<strong>Duración:</strong> {{ calcularDuracion() }}
</div>
<div class="detalle-item">
<strong>Intentos:</strong> {{ examenStore.intentos }}
</div>
<div class="detalle-item">
<strong>Estado:</strong>
<a-tag :color="getEstadoColor()">
{{ getEstadoTexto() }}
</a-tag>
</div>
<a-divider />
<h3>Resumen estadístico</h3>
<div class="estadisticas-detalle">
<a-row :gutter="16">
<a-col :span="6" v-for="stat in estadisticasDetalle" :key="stat.label">
<div class="estadistica-item">
<div class="estadistica-valor">{{ stat.value }}</div>
<div class="estadistica-label">{{ stat.label }}</div>
</div>
</a-col>
</a-row>
</div>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useExamenStore } from '../store/examen.store'
import { message } from 'ant-design-vue'
import {
CheckCircleOutlined,
CloseCircleOutlined,
StarOutlined,
PercentageOutlined,
EyeOutlined,
HomeOutlined,
PlusOutlined,
PrinterOutlined,
ShareAltOutlined,
TrophyOutlined,
BulbOutlined
} from '@ant-design/icons-vue'
const router = useRouter()
const examenStore = useExamenStore()
// Estados reactivos
const activeKeys = ref([])
const modalDetallesVisible = ref(false)
// Computed properties
const respuestasIncorrectas = computed(() => {
return examenStore.progreso.respondidas - examenStore.progreso.correctas
})
const porcentajeCorrectas = computed(() => {
if (examenStore.progreso.total === 0) return 0
return Math.round((examenStore.progreso.correctas / examenStore.progreso.total) * 100)
})
const resultadoStatus = computed(() => {
if (porcentajeCorrectas.value >= 80) return 'success'
if (porcentajeCorrectas.value >= 60) return 'warning'
return 'error'
})
const resultadoTitulo = computed(() => {
if (porcentajeCorrectas.value >= 80) return '¡Excelente resultado!'
if (porcentajeCorrectas.value >= 60) return 'Buen trabajo'
return 'Necesitas practicar más'
})
const resultadoSubtitulo = computed(() => {
return `Obtuviste ${examenStore.progreso.correctas} de ${examenStore.progreso.total} respuestas correctas (${porcentajeCorrectas.value}%)`
})
const recomendacionTexto = computed(() => {
if (porcentajeCorrectas.value >= 80) {
return 'Tu nivel es excelente. Sigue manteniendo el buen trabajo.'
} else if (porcentajeCorrectas.value >= 60) {
return 'Tienes un buen nivel, pero hay áreas que puedes mejorar.'
} else {
return 'Recomendamos repasar los temas antes de intentar nuevamente.'
}
})
const recomendaciones = computed(() => {
const recomendacionesList = []
if (porcentajeCorrectas.value >= 80) {
recomendacionesList.push({
tipo: 'fortaleza',
titulo: 'Excelente comprensión',
descripcion: 'Demuestras un dominio sólido de los temas evaluados.',
accion: 'Ver temas avanzados'
})
} else if (porcentajeCorrectas.value >= 60) {
recomendacionesList.push({
tipo: 'fortaleza',
titulo: 'Bases sólidas',
descripcion: 'Tienes buenos fundamentos en la mayoría de temas.',
accion: 'Profundizar conocimientos'
})
recomendacionesList.push({
tipo: 'mejora',
titulo: 'Áreas específicas',
descripcion: 'Algunos temas requieren más atención.',
accion: 'Repasar temas'
})
} else {
recomendacionesList.push({
tipo: 'mejora',
titulo: 'Revisión general',
descripcion: 'Recomendamos repasar todos los temas del área.',
accion: 'Iniciar repaso'
})
}
// Recomendaciones basadas en respuestas incorrectas
if (respuestasIncorrectas.value > examenStore.progreso.total / 2) {
recomendacionesList.push({
tipo: 'mejora',
titulo: 'Tiempo de estudio',
descripcion: 'Dedica más tiempo al estudio antes de evaluarte nuevamente.',
accion: 'Programar estudio'
})
}
return recomendacionesList
})
const estadisticasDetalle = computed(() => [
{ label: 'Total preguntas', value: examenStore.progreso.total },
{ label: 'Respondidas', value: examenStore.progreso.respondidas },
{ label: 'Correctas', value: examenStore.progreso.correctas },
{ label: 'Incorrectas', value: respuestasIncorrectas.value },
{ label: 'Puntaje máximo', value: examenStore.progreso.total * 10 }, // Asumiendo 10 puntos por pregunta
{ label: 'Puntaje obtenido', value: examenStore.progreso.puntaje_total },
{ label: 'Porcentaje', value: `${porcentajeCorrectas.value}%` },
{ label: 'Tasa de acierto', value: `${Math.round((examenStore.progreso.correctas / examenStore.progreso.respondidas) * 100) || 0}%` }
])
// Funciones de utilidad
const formatearEnunciado = (enunciado) => {
if (!enunciado) return ''
return enunciado.replace(/\n/g, '<br>')
}
const obtenerLetraOpcion = (key) => {
const letras = ['A', 'B', 'C', 'D', 'E', 'F']
return letras[key] || `Opción ${key + 1}`
}
const getResumenPregunta = (pregunta) => {
const estado = pregunta.es_correcta ? '✓ Correcta' : '✗ Incorrecta'
return `${estado} - Puntaje: ${pregunta.puntaje_obtenido || 0}`
}
const esOpcionCorrecta = (pregunta, opcionKey) => {
// En un sistema real, esto vendría del backend
// Por ahora, asumimos que la primera opción es correcta (para demo)
return opcionKey == 0
}
const getDescripcionResultado = (pregunta) => {
if (pregunta.es_correcta) {
return `Obtuviste ${pregunta.puntaje_obtenido || 0} puntos por esta respuesta correcta.`
} else {
return `Tu respuesta fue: ${obtenerLetraOpcion(parseInt(pregunta.respuesta_usuario))}. La respuesta correcta era: ${obtenerLetraOpcion(0)}.`
}
}
const formatFecha = (fecha) => {
if (!fecha) return 'No disponible'
const date = new Date(fecha)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
const calcularDuracion = () => {
if (!examenStore.examenActual?.hora_inicio) return 'No disponible'
const inicio = new Date(examenStore.examenActual.hora_inicio)
const fin = new Date() // Asumimos que terminó ahora
const duracionMs = fin - inicio
const minutos = Math.floor(duracionMs / (1000 * 60))
const segundos = Math.floor((duracionMs % (1000 * 60)) / 1000)
return `${minutos} min ${segundos} seg`
}
const getEstadoColor = () => {
if (porcentajeCorrectas.value >= 80) return 'green'
if (porcentajeCorrectas.value >= 60) return 'blue'
return 'red'
}
const getEstadoTexto = () => {
if (porcentajeCorrectas.value >= 80) return 'Excelente'
if (porcentajeCorrectas.value >= 60) return 'Aprobado'
return 'Reprobado'
}
// Funciones de acciones
const verDetalles = () => {
modalDetallesVisible.value = true
}
const volverADashboard = () => {
router.push('/dashboard')
}
const crearNuevoExamen = () => {
// Resetear el examen actual y volver al dashboard
examenStore.resetExamen()
examenStore.resetConfiguracion()
router.push('/dashboard')
}
const imprimirResultados = () => {
window.print()
}
const compartirResultados = () => {
const resultados = `Mis resultados del examen: ${porcentajeCorrectas.value}% de acierto (${examenStore.progreso.correctas}/${examenStore.progreso.total})`
if (navigator.share) {
navigator.share({
title: 'Resultados de mi examen',
text: resultados,
url: window.location.href
})
} else {
// Fallback: copiar al portapapeles
navigator.clipboard.writeText(resultados)
.then(() => {
message.success('Resultados copiados al portapapeles')
})
.catch(() => {
message.info(resultados)
})
}
}
// Inicialización
onMounted(() => {
// Verificar que tenemos resultados
if (!examenStore.tieneExamenActual || !examenStore.tienePreguntasGeneradas) {
message.warning('No hay resultados de examen disponibles')
router.push('/dashboard')
return
}
// Expandir la primera pregunta por defecto
if (examenStore.preguntas.length > 0) {
activeKeys.value = [examenStore.preguntas[0].id]
}
})
</script>
<style scoped>
.resultados-container {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
}
.resultados-header {
margin-bottom: 24px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
transition: all 0.3s;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-content {
display: flex;
align-items: center;
gap: 16px;
}
.stat-icon {
font-size: 32px;
}
.stat-info {
flex: 1;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #333;
line-height: 1;
}
.stat-label {
font-size: 14px;
color: #666;
margin-top: 4px;
}
.progreso-card {
margin-bottom: 24px;
}
.progreso-labels {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 12px;
color: #666;
}
.estado-final {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
.recomendacion {
font-style: italic;
color: #666;
max-width: 400px;
}
.preguntas-detalle-card {
margin-bottom: 24px;
}
.pregunta-enunciado {
margin-bottom: 20px;
padding: 16px;
background: #fafafa;
border-radius: 4px;
}
.pregunta-enunciado h4 {
margin-bottom: 8px;
color: #333;
}
.pregunta-images {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detalle-image {
max-width: 200px;
border-radius: 4px;
border: 1px solid #f0f0f0;
}
.pregunta-opciones {
margin-bottom: 20px;
}
.pregunta-opciones h4 {
margin-bottom: 12px;
color: #333;
}
.opcion-detalle {
padding: 12px;
margin-bottom: 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
transition: all 0.3s;
}
.opcion-detalle.correcta {
border-color: #52c41a;
background-color: #f6ffed;
}
.opcion-detalle.seleccionada {
border-color: #1890ff;
background-color: #e6f7ff;
}
.opcion-detalle.incorrecta-seleccionada {
border-color: #f5222d;
background-color: #fff1f0;
}
.opcion-detalle-content {
display: flex;
align-items: center;
gap: 12px;
}
.opcion-detalle-letra {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f0f0f0;
border-radius: 50%;
font-weight: bold;
font-size: 12px;
flex-shrink: 0;
position: relative;
}
.opcion-detalle.correcta .opcion-detalle-letra {
background-color: #52c41a;
color: white;
}
.opcion-detalle.seleccionada .opcion-detalle-letra {
background-color: #1890ff;
color: white;
}
.opcion-detalle.incorrecta-seleccionada .opcion-detalle-letra {
background-color: #f5222d;
color: white;
}
.correct-badge, .incorrect-badge {
position: absolute;
top: -6px;
right: -6px;
width: 12px;
height: 12px;
border-radius: 50%;
font-size: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.correct-badge {
background-color: #52c41a;
color: white;
}
.incorrect-badge {
background-color: #f5222d;
color: white;
}
.opcion-detalle-texto {
flex: 1;
line-height: 1.4;
}
.pregunta-resultado {
margin-bottom: 16px;
}
.pregunta-explicacion {
padding: 16px;
background: #f6ffed;
border-radius: 4px;
border-left: 3px solid #52c41a;
}
.pregunta-explicacion h4 {
margin-bottom: 8px;
color: #333;
}
.recomendaciones-card {
margin-bottom: 24px;
}
.acciones-card {
margin-bottom: 24px;
}
.acciones-content {
display: flex;
justify-content: center;
}
.detalles-completos {
padding: 8px;
}
.detalle-item {
margin-bottom: 12px;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}
.detalle-item:last-child {
border-bottom: none;
}
.estadisticas-detalle {
margin-top: 20px;
}
.estadistica-item {
text-align: center;
padding: 16px;
background: #fafafa;
border-radius: 4px;
margin-bottom: 16px;
}
.estadistica-valor {
font-size: 24px;
font-weight: bold;
color: #1890ff;
margin-bottom: 4px;
}
.estadistica-label {
font-size: 12px;
color: #666;
text-transform: uppercase;
}
/* Estilos para impresión */
@media print {
.resultados-container {
padding: 0;
}
.acciones-card,
.ant-space,
.ant-btn,
.ant-collapse-arrow {
display: none !important;
}
.ant-collapse-content {
display: block !important;
height: auto !important;
}
}
/* Responsive */
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.estado-final {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.acciones-content .ant-space {
flex-direction: column;
width: 100%;
}
.acciones-content .ant-btn {
width: 100%;
margin-bottom: 8px;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.stat-content {
justify-content: center;
text-align: center;
}
}
</style>
Loading…
Cancel
Save