login2026

main
elmer-20 2 months ago
parent 87e72bc029
commit 489cfd8f6b

@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\Calificacion;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class CalificacionController extends Controller
{
// ✅ Listar todas
public function index()
{
$calificaciones = Calificacion::all();
return response()->json([
'success' => true,
'data' => $calificaciones
]);
}
// ✅ Guardar nueva
public function store(Request $request)
{
$request->validate([
'nombre' => 'required|string|max:255',
'puntos_correcta' => 'required|numeric',
'puntos_incorrecta' => 'required|numeric',
'puntos_nula' => 'required|numeric',
'puntaje_maximo' => 'required|numeric',
]);
$calificacion = Calificacion::create($request->all());
return response()->json([
'success' => true,
'message' => 'Calificación creada correctamente',
'data' => $calificacion
]);
}
// ✅ Mostrar una
public function show($id)
{
$calificacion = Calificacion::find($id);
if (!$calificacion) {
return response()->json([
'success' => false,
'message' => 'No encontrada'
], 404);
}
return response()->json([
'success' => true,
'data' => $calificacion
]);
}
// ✅ Actualizar
public function update(Request $request, $id)
{
$calificacion = Calificacion::find($id);
if (!$calificacion) {
return response()->json([
'success' => false,
'message' => 'No encontrada'
], 404);
}
$request->validate([
'nombre' => 'required|string|max:255',
'puntos_correcta' => 'required|numeric',
'puntos_incorrecta' => 'required|numeric',
'puntos_nula' => 'required|numeric',
'puntaje_maximo' => 'required|numeric',
]);
$calificacion->update($request->all());
return response()->json([
'success' => true,
'message' => 'Calificación actualizada correctamente',
'data' => $calificacion
]);
}
// ✅ Eliminar
public function destroy($id)
{
$calificacion = Calificacion::find($id);
if (!$calificacion) {
return response()->json([
'success' => false,
'message' => 'No encontrada'
], 404);
}
$calificacion->delete();
return response()->json([
'success' => true,
'message' => 'Calificación eliminada correctamente'
]);
}
}

@ -1,465 +0,0 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\Academia;
use App\Models\User;
use App\Models\Examen;
use App\Models\Pregunta;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Carbon\Carbon;
class ExamenesController extends Controller
{
public function getExamenes(Request $request)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$academia = Academia::where('admin_academia_id', $user->id)->first();
if (!$academia) {
return response()->json([
'success' => false,
'message' => 'Academia no encontrada'
], 404);
}
$query = $academia->examenes()
->withCount(['preguntas', 'intentos' => function($query) {
$query->where('estado', 'finalizado');
}])
->latest();
if ($request->has('search')) {
$search = $request->search;
$query->where('titulo', 'like', "%{$search}%");
}
if ($request->has('publicado')) {
$query->where('publicado', $request->publicado);
}
if ($request->has('tipo')) {
$query->where('tipo', $request->tipo);
}
$examenes = $query->paginate(15);
return response()->json([
'success' => true,
'data' => $examenes
]);
} catch (\Exception $e) {
Log::error('Error obteniendo exámenes', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al cargar los exámenes'
], 500);
}
}
public function crearExamen(Request $request)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$academia = Academia::where('admin_academia_id', $user->id)->first();
if (!$academia) {
return response()->json([
'success' => false,
'message' => 'Academia no encontrada'
], 404);
}
$validator = Validator::make($request->all(), [
'titulo' => 'required|string|max:255',
'descripcion' => 'nullable|string',
'tipo' => 'required|in:practica,simulacro,evaluacion',
'dificultad' => 'required|in:facil,medio,dificil,avanzado',
'duracion_minutos' => 'required|integer|min:1|max:480',
'intentos_permitidos' => 'required|integer|min:1|max:10',
'puntaje_minimo' => 'required|numeric|min:0|max:100',
'preguntas_aleatorias' => 'boolean',
'mostrar_resultados' => 'boolean',
'mostrar_respuestas' => 'boolean',
'publicado' => 'boolean',
'fecha_inicio' => 'nullable|date',
'fecha_fin' => 'nullable|date|after_or_equal:fecha_inicio',
'configuracion' => 'nullable|array'
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors()
], 422);
}
DB::beginTransaction();
$examen = Examen::create([
'academia_id' => $academia->id,
'titulo' => $request->titulo,
'descripcion' => $request->descripcion,
'tipo' => $request->tipo,
'dificultad' => $request->dificultad,
'duracion_minutos' => $request->duracion_minutos,
'intentos_permitidos' => $request->intentos_permitidos,
'puntaje_minimo' => $request->puntaje_minimo,
'preguntas_aleatorias' => $request->preguntas_aleatorias ?? 0,
'mezclar_opciones' => $request->mezclar_opciones ?? true,
'mostrar_resultados' => $request->mostrar_resultados ?? true,
'mostrar_respuestas' => $request->mostrar_respuestas ?? false,
'mostrar_explicaciones' => $request->mostrar_explicaciones ?? false,
'activar_timer' => $request->activar_timer ?? true,
'permitir_navegacion' => $request->permitir_navegacion ?? true,
'permitir_revisar' => $request->permitir_revisar ?? true,
'publicado' => $request->publicado ?? false,
'fecha_inicio' => $request->fecha_inicio,
'fecha_fin' => $request->fecha_fin,
'orden' => $request->orden ?? 1,
'configuracion' => $request->configuracion ?? []
]);
DB::commit();
Log::info('Examen creado por admin', [
'academia_id' => $academia->id,
'examen_id' => $examen->id,
'admin_id' => $user->id
]);
return response()->json([
'success' => true,
'message' => 'Examen creado exitosamente',
'data' => $examen
], 201);
} catch (\Exception $e) {
DB::rollBack();
Log::error('Error creando examen', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al crear el examen'
], 500);
}
}
public function getExamen($examenId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$academia = Academia::where('admin_academia_id', $user->id)->first();
if (!$academia) {
return response()->json([
'success' => false,
'message' => 'Academia no encontrada'
], 404);
}
$examen = Examen::where('academia_id', $academia->id)
->with(['preguntas'])
->find($examenId);
if (!$examen) {
return response()->json([
'success' => false,
'message' => 'Examen no encontrado'
], 404);
}
$estadisticas = DB::table('intentos_examen')
->where('examen_id', $examenId)
->where('estado', 'finalizado')
->selectRaw('COUNT(*) as total_intentos')
->selectRaw('AVG(porcentaje) as promedio')
->selectRaw('SUM(CASE WHEN aprobado = 1 THEN 1 ELSE 0 END) as aprobados')
->first();
return response()->json([
'success' => true,
'data' => [
'examen' => $examen,
'estadisticas' => $estadisticas
]
]);
} catch (\Exception $e) {
Log::error('Error obteniendo examen', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al cargar el examen'
], 500);
}
}
public function actualizarExamen(Request $request, $examenId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$academia = Academia::where('admin_academia_id', $user->id)->first();
if (!$academia) {
return response()->json([
'success' => false,
'message' => 'Academia no encontrada'
], 404);
}
$examen = Examen::where('academia_id', $academia->id)->find($examenId);
if (!$examen) {
return response()->json([
'success' => false,
'message' => 'Examen no encontrado'
], 404);
}
$validator = Validator::make($request->all(), [
'titulo' => 'sometimes|string|max:255',
'descripcion' => 'nullable|string',
'tipo' => 'sometimes|in:practica,simulacro,evaluacion',
'duracion_minutos' => 'sometimes|integer|min:1|max:480',
'intentos_permitidos' => 'sometimes|integer|min:1|max:10',
'puntaje_minimo' => 'sometimes|numeric|min:0|max:100',
'preguntas_aleatorias' => 'boolean',
'mostrar_resultados' => 'boolean',
'mostrar_respuestas' => 'boolean',
'publicado' => 'boolean',
'fecha_inicio' => 'nullable|date',
'fecha_fin' => 'nullable|date|after_or_equal:fecha_inicio',
'configuracion' => 'nullable|array'
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'errors' => $validator->errors()
], 422);
}
$examen->update($request->only([
'titulo', 'descripcion', 'tipo', 'duracion_minutos', 'intentos_permitidos',
'puntaje_minimo', 'preguntas_aleatorias', 'mostrar_resultados',
'mostrar_respuestas', 'publicado', 'fecha_inicio', 'fecha_fin', 'configuracion'
]));
Log::info('Examen actualizado por admin', [
'academia_id' => $academia->id,
'examen_id' => $examen->id,
'admin_id' => $user->id
]);
return response()->json([
'success' => true,
'message' => 'Examen actualizado exitosamente',
'data' => $examen
]);
} catch (\Exception $e) {
Log::error('Error actualizando examen', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al actualizar el examen'
], 500);
}
}
public function eliminarExamen($examenId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$academia = Academia::where('admin_academia_id', $user->id)->first();
if (!$academia) {
return response()->json([
'success' => false,
'message' => 'Academia no encontrada'
], 404);
}
$examen = Examen::where('academia_id', $academia->id)->find($examenId);
if (!$examen) {
return response()->json([
'success' => false,
'message' => 'Examen no encontrado'
], 404);
}
$tieneIntentos = $examen->intentos()->exists();
if ($tieneIntentos) {
return response()->json([
'success' => false,
'message' => 'No se puede eliminar un examen con intentos realizados'
], 400);
}
$examen->delete();
Log::info('Examen eliminado por admin', [
'academia_id' => $academia->id,
'examen_id' => $examenId,
'admin_id' => $user->id
]);
return response()->json([
'success' => true,
'message' => 'Examen eliminado exitosamente'
]);
} catch (\Exception $e) {
Log::error('Error eliminando examen', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al eliminar el examen'
], 500);
}
}
public function getResultadosExamen($examenId)
{
try {
$user = auth()->user();
if (!$user->hasRole('administrador')) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$academia = Academia::where('admin_academia_id', $user->id)->first();
if (!$academia) {
return response()->json([
'success' => false,
'message' => 'Academia no encontrada'
], 404);
}
$examen = Examen::where('academia_id', $academia->id)->find($examenId);
if (!$examen) {
return response()->json([
'success' => false,
'message' => 'Examen no encontrado'
], 404);
}
$resultados = DB::table('intentos_examen')
->join('users', 'intentos_examen.user_id', '=', 'users.id')
->where('intentos_examen.examen_id', $examenId)
->where('intentos_examen.estado', 'finalizado')
->select(
'users.id as estudiante_id',
'users.name as estudiante_nombre',
'users.email as estudiante_email',
'intentos_examen.numero_intento',
'intentos_examen.porcentaje',
'intentos_examen.aprobado',
'intentos_examen.tiempo_utilizado',
'intentos_examen.finalizado_en'
)
->orderBy('intentos_examen.porcentaje', 'desc')
->get();
$estadisticas = [
'total_estudiantes' => $resultados->groupBy('estudiante_id')->count(),
'promedio' => $resultados->avg('porcentaje'),
'aprobados' => $resultados->where('aprobado', true)->count(),
'reprobados' => $resultados->where('aprobado', false)->count(),
'tiempo_promedio' => $resultados->avg('tiempo_utilizado')
];
return response()->json([
'success' => true,
'data' => [
'resultados' => $resultados,
'estadisticas' => $estadisticas
]
]);
} catch (\Exception $e) {
Log::error('Error obteniendo resultados', [
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error al cargar los resultados'
], 500);
}
}
}

@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\Postulante;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
class PostulanteController extends Controller
{
public function obtenerPostulantes(Request $request)
{
$query = Postulante::query();
if ($request->buscar) {
$query->where(function ($q) use ($request) {
$q->where('name', 'like', "%{$request->buscar}%")
->orWhere('email', 'like', "%{$request->buscar}%")
->orWhere('dni', 'like', "%{$request->buscar}%");
});
}
$postulantes = $query->orderBy('id', 'desc')
->paginate(20);
return response()->json([
'success' => true,
'data' => $postulantes
]);
}
public function actualizarPostulante(Request $request, $id)
{
$postulante = Postulante::findOrFail($id);
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:postulantes,email,' . $postulante->id,
'dni' => 'required|string|max:20|unique:postulantes,dni,' . $postulante->id,
'password' => 'nullable|string|min:6'
]);
$postulante->update([
'name' => $request->name,
'email' => $request->email,
'dni' => $request->dni,
]);
// 🔹 Solo si envían nueva contraseña
if ($request->filled('password')) {
$postulante->password = $request->password;
$postulante->save();
}
return response()->json([
'success' => true,
'message' => 'Postulante actualizado correctamente',
'data' => $postulante
]);
}
}

@ -11,7 +11,7 @@ use Illuminate\Support\Facades\DB;
class ReglaAreaProcesoController extends Controller
{
ion areasProcesos()
public function areasProcesos()
{
$areasProcesos = DB::table('area_proceso as ap')
->leftJoin('reglas_area_proceso as r', 'ap.id', '=', 'r.area_proceso_id')

@ -431,7 +431,7 @@ public function iniciarExamen(Request $request)
public function responderPregunta($preguntaAsignadaId, Request $request)
{
$request->validate([
'respuesta' => 'required|string'
'respuesta' => 'nullable|string'
]);
$preguntaAsignada = PreguntaAsignada::with(['examen', 'pregunta'])

@ -21,7 +21,7 @@ class PreguntaAsignada extends Model
];
protected $casts = [
'es_correcta' => 'boolean',
'es_correcta' => 'integer',
'puntaje' => 'decimal:2'
];

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ResultadoExamen extends Model
{
protected $table = 'resultados_examenes';
protected $fillable = [
'postulante_id',
'examen_id',
'total_puntos',
'correctas_por_curso',
'incorrectas_por_curso',
'preguntas_totales_por_curso',
'total_correctas',
'total_incorrectas',
'total_nulas',
'porcentaje_correctas',
'calificacion_sobre_20',
'orden_merito',
'probabilidad_ingreso',
'programa_recomendado',
];
public function postulante()
{
return $this->belongsTo(Postulante::class);
}
public function examen()
{
return $this->belongsTo(Examen::class);
}
}

@ -115,29 +115,49 @@ public function obtenerPreguntasExamen(Examen $examen): array
public function guardarRespuesta(PreguntaAsignada $pa, string $respuesta): array
{
if ($pa->estado === 'respondida') {
return ['success' => false, 'message' => 'Ya respondida'];
}
public function guardarRespuesta(PreguntaAsignada $pa, ?string $respuesta): array
{
if ($pa->estado === 'respondida') {
return ['success' => false, 'message' => 'Ya respondida'];
}
$esCorrecta = $respuesta === $pa->pregunta->respuesta_correcta;
// 🔹 Si está en blanco
if (empty($respuesta)) {
$pa->update([
'respuesta_usuario' => $respuesta,
'es_correcta' => $esCorrecta,
'puntaje_obtenido' => $esCorrecta ? $pa->puntaje_base : 0,
'respuesta_usuario' => null,
'es_correcta' => 2, // 2 = blanco
'puntaje_obtenido' => 0,
'estado' => 'respondida',
'respondida_at' => now()
]);
return [
'success' => true,
'correcta' => $esCorrecta,
'puntaje' => $pa->puntaje_obtenido
'correcta' => 2,
'puntaje' => 0
];
}
// 🔹 Si respondió algo
$esCorrecta = $respuesta === $pa->pregunta->respuesta_correcta;
$pa->update([
'respuesta_usuario' => $respuesta,
'es_correcta' => $esCorrecta ? 1 : 0,
'puntaje_obtenido' => $esCorrecta ? $pa->puntaje_base : 0,
'estado' => 'respondida',
'respondida_at' => now()
]);
return [
'success' => true,
'correcta' => $esCorrecta ? 1 : 0,
'puntaje' => $pa->puntaje_obtenido
];
}
/**
* Finalizar examen
*/

@ -6,7 +6,6 @@ use App\Http\Controllers\AuthController;
use App\Http\Controllers\AcademiaController;
use App\Http\Controllers\VinculacionController;
use App\Http\Controllers\Administracion\administradorController;
use App\Http\Controllers\Administracion\ExamenesController;
use App\Http\Controllers\Administracion\AreaController;
use App\Http\Controllers\Administracion\CursoController;
use App\Http\Controllers\Administracion\PreguntaController;
@ -16,8 +15,11 @@ use App\Http\Controllers\ExamenController;
use App\Http\Controllers\Administracion\ReglaAreaProcesoController;
use App\Http\Controllers\Administracion\ProcesoAdmisionController;
use App\Http\Controllers\Administracion\ProcesoAdmisionDetalleController;
use App\Http\Controllers\Administracion\PostulanteController;
use App\Http\Administracion\CalificacionController;
use App\Models\ProcesoAdmisionDetalle;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
@ -74,6 +76,14 @@ Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
});
Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
Route::get('/postulantes', [PostulanteController::class, 'obtenerPostulantes']);
Route::put('/postulantes/{id}', [PostulanteController::class, 'actualizarPostulante']);
});
Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
@ -85,6 +95,17 @@ Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
});
Route::middleware('auth:sanctum')->group(function () {
Route::get('/calificaciones', [CalificacionController::class, 'index']);
Route::post('/calificaciones', [CalificacionController::class, 'store']);
Route::get('/calificaciones/{id}', [CalificacionController::class, 'show']);
Route::put('/calificaciones/{id}', [CalificacionController::class, 'update']);
Route::delete('/calificaciones/{id}', [CalificacionController::class, 'destroy']);
});
Route::prefix('postulante')->group(function () {

@ -127,6 +127,17 @@ const routes = [
path: '/admin/dashboard/procesos/:id/detalles',
name: 'ProcesoAdmisionDetalles',
component: () => import('../views/administrador/procesoadmision/ProcesoAdmisionDetalles.vue')
},
{
path: '/admin/dashboard/lista-calificacion',
name: 'CalificacionList',
component: () => import('../views/administrador/Procesos/CalificacionTest.vue')
},
{
path: '/admin/dashboard/lista-postulantes',
name: 'PostulantesList',
component: () => import('../views/administrador/estudiantes/ListPostulantes.vue')
}
]
},

@ -12,17 +12,23 @@ export const useExamenStore = defineStore('examenStore', {
error: null,
}),
actions: {
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
}
},
async fetchProcesos() {
try {
this.cargando = true
const { data } = await api.get('/examen/procesos')
// ✅ normaliza
this.procesos = (data || []).map(p => ({
...p,
requiere_pago: p.requiere_pago === 1 || p.requiere_pago === '1' || p.requiere_pago === true
}))
} catch (e) {
this.error = e.response?.data?.message || e.message
} finally {
this.cargando = false
}
},
async fetchAreas(proceso_id) {
try {
@ -95,17 +101,29 @@ export const useExamenStore = defineStore('examenStore', {
},
async responderPregunta(preguntaId, respuesta) {
try {
const { data } = await api.post(`/examen/pregunta/${preguntaId}/responder`, { respuesta })
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 }
}
},
async responderPregunta(preguntaId, respuesta) {
try {
const { data } = await api.post(
`/examen/pregunta/${preguntaId}/responder`,
{ respuesta }
)
const index = this.preguntas.findIndex(p => p.id === preguntaId)
if (index !== -1 && data.success) {
this.preguntas[index].respuesta = respuesta
this.preguntas[index].es_correcta = data.correcta // 1, 0 o 2
this.preguntas[index].puntaje = data.puntaje
}
return data
} catch (e) {
this.error = e.response?.data?.message || e.message
return { success: false, message: this.error }
}
},
async finalizarExamen(examenId) {
try {

@ -0,0 +1,181 @@
<template>
<div class="page-wrapper">
<a-card :bordered="false" class="main-card">
<!-- Header -->
<div class="header">
<h2>Gestión de Calificaciones</h2>
<a-button type="primary" @click="openModal()">
Nueva Calificación
</a-button>
</div>
<!-- Tabla -->
<a-table
:columns="columns"
:data-source="calificaciones"
:loading="loading"
row-key="id"
bordered
>
<template #actions="{ record }">
<a-space>
<a-button type="link" @click="openModal(record)">
Editar
</a-button>
<a-popconfirm
title="¿Seguro de eliminar?"
@confirm="eliminar(record.id)"
>
<a-button type="link" danger>
Eliminar
</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-card>
<!-- Modal -->
<a-modal
v-model:open="modalVisible"
:title="form.id ? 'Editar Calificación' : 'Nueva Calificación'"
@ok="guardar"
@cancel="cerrarModal"
ok-text="Guardar"
>
<a-form layout="vertical">
<a-form-item label="Nombre">
<a-input v-model:value="form.nombre" />
</a-form-item>
<a-form-item label="Puntos Correcta">
<a-input-number v-model:value="form.puntos_correcta" style="width:100%" />
</a-form-item>
<a-form-item label="Puntos Incorrecta">
<a-input-number v-model:value="form.puntos_incorrecta" style="width:100%" />
</a-form-item>
<a-form-item label="Puntos Nula">
<a-input-number v-model:value="form.puntos_nula" style="width:100%" />
</a-form-item>
<a-form-item label="Puntaje Máximo">
<a-input-number v-model:value="form.puntaje_maximo" style="width:100%" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import api from '../../../axios'
const calificaciones = ref([])
const loading = ref(false)
const modalVisible = ref(false)
const form = ref({
id: null,
nombre: '',
puntos_correcta: 0,
puntos_incorrecta: 0,
puntos_nula: 0,
puntaje_maximo: 0
})
const columns = [
{ title: 'Nombre', dataIndex: 'nombre' },
{ title: 'Correcta', dataIndex: 'puntos_correcta' },
{ title: 'Incorrecta', dataIndex: 'puntos_incorrecta' },
{ title: 'Nula', dataIndex: 'puntos_nula' },
{ title: 'Máximo', dataIndex: 'puntaje_maximo' },
{ title: 'Acciones', key: 'actions', slots: { customRender: 'actions' } }
]
const cargar = async () => {
loading.value = true
try {
const { data } = await api .getAll()
calificaciones.value = data.data
} catch (error) {
message.error('Error al cargar')
} finally {
loading.value = false
}
}
const openModal = (record = null) => {
if (record) {
form.value = { ...record }
} else {
form.value = {
id: null,
nombre: '',
puntos_correcta: 0,
puntos_incorrecta: 0,
puntos_nula: 0,
puntaje_maximo: 0
}
}
modalVisible.value = true
}
const cerrarModal = () => {
modalVisible.value = false
}
const guardar = async () => {
try {
if (form.value.id) {
await api .update(form.value.id, form.value)
message.success('Actualizado correctamente')
} else {
await api .create(form.value)
message.success('Creado correctamente')
}
cerrarModal()
cargar()
} catch (error) {
message.error('Error al guardar')
}
}
const eliminar = async (id) => {
try {
await api .delete(id)
message.success('Eliminado correctamente')
cargar()
} catch (error) {
message.error('Error al eliminar')
}
}
onMounted(() => {
cargar()
})
</script>
<style scoped>
.page-wrapper {
padding: 24px;
}
.main-card {
border-radius: 12px;
}
.header {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
}
</style>

@ -0,0 +1,201 @@
<template>
<div class="postulantes-container">
<!-- Header -->
<div class="header">
<div>
<h2 class="page-title">Gestión de Postulantes</h2>
<p class="page-subtitle">Administra los usuarios registrados</p>
</div>
<a-input-search
v-model:value="search"
placeholder="Buscar por nombre, email o DNI"
style="width: 300px"
@search="fetchPostulantes"
/>
</div>
<!-- Tabla -->
<a-card>
<a-table
:columns="columns"
:data-source="postulantes"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<!-- Nombre -->
<template v-if="column.key === 'name'">
<strong>{{ record.name }}</strong>
</template>
<!-- Email -->
<template v-else-if="column.key === 'email'">
{{ record.email }}
</template>
<!-- DNI -->
<template v-else-if="column.key === 'dni'">
<a-tag color="blue">{{ record.dni }}</a-tag>
</template>
<!-- Última actividad -->
<template v-else-if="column.key === 'last_activity'">
{{ record.last_activity ?? 'Sin actividad' }}
</template>
<!-- Acciones -->
<template v-else-if="column.key === 'acciones'">
<a-space>
<a-button type="text" @click="editar(record)">
Editar
</a-button>
</a-space>
</template>
</template>
</a-table>
</a-card>
<!-- Modal Editar -->
<a-modal
v-model:open="showModal"
title="Editar Postulante"
@ok="guardarCambios"
@cancel="resetForm"
>
<a-form layout="vertical">
<a-form-item label="Nombre">
<a-input v-model:value="form.name" />
</a-form-item>
<a-form-item label="Email">
<a-input v-model:value="form.email" />
</a-form-item>
<a-form-item label="DNI">
<a-input v-model:value="form.dni" />
</a-form-item>
<a-form-item label="Nueva contraseña (opcional)">
<a-input-password v-model:value="form.password" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import api from '../../../axios'
const postulantes = ref([])
const loading = ref(false)
const search = ref('')
const showModal = ref(false)
const editingId = ref(null)
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0
})
const form = reactive({
name: '',
email: '',
dni: '',
password: ''
})
const columns = [
{ title: 'Nombre', dataIndex: 'name', key: 'name' },
{ title: 'Email', dataIndex: 'email', key: 'email' },
{ title: 'DNI', dataIndex: 'dni', key: 'dni' },
{ title: 'Última actividad', dataIndex: 'last_activity', key: 'last_activity' },
{ title: 'Acciones', key: 'acciones', align: 'center' }
]
const fetchPostulantes = async () => {
loading.value = true
try {
const { data } = await api.get('/admin/postulantes', {
params: {
page: pagination.current,
buscar: search.value
}
})
postulantes.value = data.data.data
pagination.total = data.data.total
} catch (error) {
message.error('Error al cargar postulantes')
} finally {
loading.value = false
}
}
const editar = (record) => {
editingId.value = record.id
form.name = record.name
form.email = record.email
form.dni = record.dni
form.password = ''
showModal.value = true
}
const guardarCambios = async () => {
try {
await api.put(`/admin/postulantes/${editingId.value}`, form)
message.success('Postulante actualizado correctamente')
showModal.value = false
fetchPostulantes()
} catch (error) {
message.error(error.response?.data?.message || 'Error al actualizar')
}
}
const handleTableChange = (pag) => {
pagination.current = pag.current
fetchPostulantes()
}
const resetForm = () => {
showModal.value = false
editingId.value = null
}
onMounted(fetchPostulantes)
</script>
<style scoped>
.postulantes-container {
padding: 10px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.page-title {
margin: 0;
font-size: 22px;
font-weight: 600;
}
.page-subtitle {
font-size: 14px;
color: #666;
}
</style>

@ -24,9 +24,8 @@
</div>
</div>
<!-- Desktop Actions -->
<div class="header-right">
<!-- Perfil -->
<a-dropdown :trigger="['click']" placement="bottomRight" class="profile-dropdown-wrapper">
<div class="profile-trigger">
<div class="profile-info">
@ -138,8 +137,7 @@
<span class="menu-label">Dashboard</span>
</div>
</a-menu-item>
<!-- Gestión Académica -->
<div class="menu-section" v-if="!sidebarCollapsed">
<div class="section-label">Gestión Académica</div>
</div>
@ -199,6 +197,12 @@
<span class="menu-label">Reglas</span>
</div>
</a-menu-item>
<a-menu-item key="examenes-calificaciones-lista" class="menu-item">
<div class="menu-item-content">
<UnorderedListOutlined class="menu-icon" />
<span class="menu-label">Calificacion</span>
</div>
</a-menu-item>
</a-sub-menu>
<a-sub-menu key="procesos" class="sub-menu">
@ -413,14 +417,13 @@ const handleMenuSelect = ({ key }) => {
const routes = {
'dashboard': { name: 'Dashboard' },
'estudiantes-lista': { name: 'AcademiaEstudiantes' },
'estudiantes-nuevo': { name: 'AcademiaEstudianteNuevo' },
'estudiantes-lista': { name: 'PostulantesList' },
'examenes-calificaciones-lista': { name: 'CalificacionList' },
'examenes-proceso-lista': { name: 'Procesos' },
'examenes-area-lista': { name: 'Areas' },
'examenes-curso-lista': { name: 'Cursos' },
'examenes-reglas-lista': { name: 'Reglas' },
'procesos-lista': { name: 'ProcesosAdmisionList' },
'lista-cursos': { name: 'AcademiaCursos' },
'resultados': { name: 'AcademiaResultados' },

@ -1,6 +1,185 @@
<template>
<a-spin :spinning="loading">
<div class="topbar">
<div class="topbarLeft">
<div class="hello">Bienvenido, {{ authStore.userName}}</div>
<div class="sub">DNI: {{ authStore.userDni || 'No registrado' }}</div>
</div>
<div class="topbarRight">
<div class="statusLine">
<span class="label">Estado:</span>
<a-badge :status="eligibilityUi.badge" :text="eligibilityUi.text" />
</div>
<a-button class="btnTop" @click="fetchDashboard" :loading="loading" block>
Actualizar
</a-button>
</div>
</div>
<a-divider />
<div class="testBox">
<div class="testHead">
<div class="testHeadLeft">
<div class="testTitle">Tu test de admisión</div>
<div class="testSub">
Responde con calma. Busca un lugar tranquilo. (10 preguntas 10 min aprox.)
</div>
</div>
<div class="testHeadRight">
<a-button
type="primary"
size="large"
class="btnTest"
:disabled="!canStartTest"
block
@click="onStartTest"
>
{{ testCtaText }}
</a-button>
<div class="microHelp" v-if="!canStartTest">No disponible por el momento.</div>
</div>
</div>
<div class="testInfoGrid">
<div class="infoItem">
<div class="infoK">Estado</div>
<div class="infoV">
<a-badge :status="testStatusUi.badge" :text="testStatusUi.text" />
</div>
</div>
<div class="infoItem">
<div class="infoK">Tiempo restante</div>
<div class="infoV">
<span class="strong" :class="{ warn: expiresSoon && timeRemainingText !== 'Vencido' }">
{{ timeRemainingText }}
</span>
<span v-if="expiresSoon && timeRemainingText !== 'Vencido'" class="hintInline">
(vence pronto)
</span>
</div>
</div>
<div class="infoItem">
<div class="infoK">Disponible desde</div>
<div class="infoV">
{{ state.eligibility.testAvailableAt ? fmtDate(state.eligibility.testAvailableAt) : "—" }}
</div>
</div>
<div class="infoItem">
<div class="infoK">Fecha límite</div>
<div class="infoV">
{{ state.eligibility.testExpiresAt ? fmtDate(state.eligibility.testExpiresAt) : "—" }}
</div>
</div>
</div>
<div class="mt12">
<a-alert
v-if="state.eligibility.testStatus === 'COMPLETADO'"
type="success"
show-icon
message="¡Listo! Ya completaste el test."
description="Ahora puedes revisar los procesos disponibles."
/>
<a-alert
v-else-if="!state.eligibility.hasTestAssigned"
type="info"
show-icon
message="Aún no tienes un test asignado."
description="Vuelve a revisar en unos minutos."
/>
<a-alert
v-else
type="info"
show-icon
message="Importante"
description="Al iniciar o continuar, el tiempo puede comenzar a correr según la configuración del proceso."
/>
</div>
<a-alert
v-if="!state.eligibility.isEligibleToApply && state.eligibility.reasons?.length"
type="warning"
show-icon
message="Requisitos pendientes"
class="mt12"
>
<template #description>
<ul class="reasons">
<li v-for="(r, idx) in state.eligibility.reasons" :key="idx">{{ r }}</li>
</ul>
</template>
</a-alert>
</div>
<a-divider />
<a-divider />
<!-- Procesos -->
<div class="section">
<div class="sectionHead">
<div>
<div class="sectionTitle">Procesos disponibles</div>
<div class="sectionSub">Revisa fechas y postula cuando esté habilitado.</div>
</div>
</div>
<div class="tableWrap mt12">
<a-table
:columns="processColumns"
:data-source="state.availableProcesses"
row-key="id"
:pagination="{ pageSize: 6 }"
:scroll="{ x: 720 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'startDate'">
{{ fmtDate(record.startDate) }}
</template>
<template v-else-if="column.key === 'endDate'">
{{ fmtDate(record.endDate) }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag class="chip">{{ record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<div class="actions">
<a-button size="small" block @click="onViewProcess(record)">Ver</a-button>
<a-button size="small" type="primary" block :disabled="!record.canApply" @click="onApply(record)">
Postular
</a-button>
</div>
<div v-if="!record.canApply" class="microHelp">Este proceso aún no permite postular.</div>
</template>
</template>
</a-table>
</div>
</div>
<a-divider />
</a-spin>
</template>
<script setup>
import { computed, onMounted, reactive, ref, onBeforeUnmount } from "vue";
import { message, Modal } from "ant-design-vue";
import { useAuthStore } from '../../store/postulanteStore'
const authStore = useAuthStore()
const loading = ref(false);
const nowTick = ref(Date.now());
@ -13,7 +192,7 @@ const state = reactive({
isEligibleToApply: false,
reasons: [],
hasTestAssigned: true,
testStatus: "PENDIENTE", // PENDIENTE | EN_PROGRESO | COMPLETADO | NO_DISPONIBLE
testStatus: "PENDIENTE",
testAvailableAt: null,
testExpiresAt: null,
testUrl: null,
@ -21,13 +200,10 @@ const state = reactive({
availableProcesses: [],
});
/** ---------------------------
* Helpers tiempo (sin dayjs)
* --------------------------- */
function parseDate(val) {
if (!val) return null;
// soporta "YYYY-MM-DD HH:mm" o ISO
const iso = val.includes("T") ? val : val.replace(" ", "T");
const iso = String(val).includes("T") ? String(val) : String(val).replace(" ", "T");
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d;
}
@ -41,36 +217,36 @@ function msToHuman(ms) {
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function fmtDate(val) {
const d = parseDate(val);
if (!d) return val || "—";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
}
/** ---------------------------
* Computed
* --------------------------- */
const totalApplications = computed(() => state.applications.length);
const applicationsByStatus = computed(() => {
const map = {};
for (const a of state.applications) map[a.status] = (map[a.status] || 0) + 1;
return map;
});
const eligibilityTag = computed(() => {
const eligibilityUi = computed(() => {
return state.eligibility.isEligibleToApply
? { color: "green", text: "Apto para postular" }
: { color: "red", text: "No apto para postular" };
? { badge: "success", text: "Apto para postular" }
: { badge: "error", text: "No apto para postular" };
});
const testStatusUi = computed(() => {
const s = state.eligibility.testStatus;
if (s === "EN_PROGRESO") return { color: "blue", text: "En progreso" };
if (s === "COMPLETADO") return { color: "green", text: "Completado" };
if (s === "NO_DISPONIBLE") return { color: "default", text: "No disponible" };
return { color: "orange", text: "Pendiente" };
if (s === "EN_PROGRESO") return { badge: "processing", text: "En progreso" };
if (s === "COMPLETADO") return { badge: "success", text: "Completado" };
if (s === "NO_DISPONIBLE") return { badge: "default", text: "No disponible" };
return { badge: "warning", text: "Pendiente" };
});
const testCtaText = computed(() => {
const s = state.eligibility.testStatus;
if (s === "EN_PROGRESO") return "Continuar test";
if (s === "COMPLETADO") return "Test completado";
if (s === "COMPLETADO") return "Ver estado";
if (s === "NO_DISPONIBLE") return "No disponible";
return "Iniciar test";
});
@ -90,29 +266,34 @@ const testExpireMs = computed(() => {
});
const expiresSoon = computed(() => {
// pronto si queda <= 24h
const ms = testExpireMs.value;
return ms !== null && ms > 0 && ms <= 24 * 60 * 60 * 1000;
});
const processColumns = [
{ title: "Proceso", dataIndex: "name", key: "name" },
{ title: "Inicio", dataIndex: "startDate", key: "startDate", width: 140 },
{ title: "Fin", dataIndex: "endDate", key: "endDate", width: 140 },
{ title: "Estado", dataIndex: "status", key: "status", width: 120 },
{ title: "Vacantes", dataIndex: "vacancies", key: "vacancies", width: 110 },
{ title: "Acciones", key: "actions", width: 220 },
];
const timeRemainingText = computed(() => {
const ms = testExpireMs.value;
if (ms === null) return "—";
if (ms <= 0) return "Vencido";
return msToHuman(ms);
});
const processColumns = computed(() => [
{ title: "Proceso", dataIndex: "name", key: "name", ellipsis: true },
{ title: "Inicio", dataIndex: "startDate", key: "startDate", responsive: ["md"] },
{ title: "Fin", dataIndex: "endDate", key: "endDate", responsive: ["md"] },
{ title: "Estado", dataIndex: "status", key: "status", responsive: ["sm"] },
{ title: "Vacantes", dataIndex: "vacancies", key: "vacancies", responsive: ["sm"] },
{ title: "Acciones", key: "actions" },
]);
/** ---------------------------
* API (reemplaza por tus endpoints)
* --------------------------- */
const api = {
async getDashboard() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
applicant: { id: 7, nombres: "Juan Pérez", documento: "DNI 12345678" },
applications: [
{ id: 1, processName: "Admisión 2025-II", status: "NO_APTO", createdAt: "2025-09-05" },
{ id: 2, processName: "Admisión 2026-I", status: "EN_REVISION", createdAt: "2026-02-03" },
@ -125,7 +306,7 @@ const api = {
testStatus: "PENDIENTE",
testAvailableAt: "2026-02-10 09:00",
testExpiresAt: "2026-02-20 23:59",
testUrl: "/postulante/test",
testUrl: "portal-postulante/test",
},
availableProcesses: [
{ id: 10, name: "Admisión 2026-I", startDate: "2026-02-01", endDate: "2026-02-20", status: "ABIERTO", vacancies: 120, canApply: true },
@ -167,12 +348,11 @@ async function onStartTest() {
Modal.confirm({
title: "Test de Admisión",
content: "Al iniciar o continuar, el tiempo puede comenzar a correr según la configuración. ¿Deseas continuar?",
okText: "",
okText: "Continuar",
cancelText: "Cancelar",
async onOk() {
try {
await api.startTest();
message.success("Listo.");
if (state.eligibility.testUrl) window.location.href = state.eligibility.testUrl;
} catch {
message.error("No se pudo iniciar el test.");
@ -183,6 +363,7 @@ async function onStartTest() {
async function onApply(process) {
if (!process.canApply) return;
Modal.confirm({
title: "Confirmar postulación",
content: `¿Deseas postular al proceso "${process.name}"?`,
@ -214,396 +395,270 @@ onBeforeUnmount(() => {
});
</script>
<template>
<a-spin :spinning="loading">
<a-space direction="vertical" size="large" style="width: 100%">
<!-- Encabezado del postulante -->
<a-card class="soft-card">
<div class="header-row">
<div>
<div class="h-title">Bienvenido, {{ state.applicant.nombres }}</div>
<div class="h-sub">{{ state.applicant.documento }}</div>
</div>
<div class="header-actions">
<a-tag :color="eligibilityTag.color" class="pill-tag">{{ eligibilityTag.text }}</a-tag>
</div>
</div>
<a-alert
v-if="!state.eligibility.isEligibleToApply && state.eligibility.reasons?.length"
type="warning"
show-icon
message="Requisitos pendientes"
style="margin-top: 14px"
>
<template #description>
<ul style="margin: 8px 0 0 18px">
<li v-for="(r, idx) in state.eligibility.reasons" :key="idx">{{ r }}</li>
</ul>
</template>
</a-alert>
</a-card>
<!-- HERO: TEST DE ADMISIÓN (MUY RESALTADO) -->
<a-badge-ribbon
:text="state.eligibility.testStatus === 'COMPLETADO' ? 'LISTO' : 'IMPORTANTE'"
:color="state.eligibility.testStatus === 'COMPLETADO' ? 'green' : 'red'"
>
<a-card class="test-hero" :bordered="false">
<div class="test-grid">
<div>
<div class="test-kicker">Test de admisión</div>
<div class="test-title">Tu evaluación está aquí</div>
<div class="test-meta">
<a-tag :color="testStatusUi.color" class="pill-tag">
Estado: {{ testStatusUi.text }}
</a-tag>
<a-tag v-if="expiresSoon" color="volcano" class="pill-tag">
Vence pronto: {{ msToHuman(testExpireMs || 0) }}
</a-tag>
<a-tag v-else-if="testExpireMs !== null && testExpireMs > 0" color="blue" class="pill-tag">
Tiempo restante: {{ msToHuman(testExpireMs) }}
</a-tag>
</div>
<div class="test-dates" v-if="state.eligibility.testAvailableAt || state.eligibility.testExpiresAt">
<div v-if="state.eligibility.testAvailableAt">
<span class="muted">Disponible desde:</span> <b>{{ state.eligibility.testAvailableAt }}</b>
</div>
<div v-if="state.eligibility.testExpiresAt">
<span class="muted">Fecha límite:</span> <b>{{ state.eligibility.testExpiresAt }}</b>
</div>
</div>
<a-alert
v-if="state.eligibility.testStatus === 'COMPLETADO'"
type="success"
show-icon
message="Tu test ya fue completado. ¡Bien!"
style="margin-top: 12px"
/>
<a-alert
v-else-if="!state.eligibility.hasTestAssigned"
type="info"
show-icon
message="Aún no tienes un test asignado."
style="margin-top: 12px"
/>
<a-alert
v-else-if="!state.eligibility.isEligibleToApply"
type="warning"
show-icon
message="No estás apto para postular por ahora. Revisa los requisitos pendientes."
style="margin-top: 12px"
/>
</div>
<style scoped>
<div class="test-cta">
<div class="cta-box">
<div class="cta-label">Acción</div>
<a-button
type="primary"
size="large"
block
class="cta-btn"
:disabled="!canStartTest"
@click="onStartTest"
>
{{ testCtaText }}
</a-button>
<div class="cta-hint" v-if="canStartTest">
Entra cuando estés listo. Si estás en progreso, puedes continuar.
</div>
<div class="cta-hint" v-else>
No disponible por el momento.
</div>
<a-divider style="margin: 14px 0" />
<div class="cta-stats">
<div class="stat">
<div class="stat-num">{{ totalApplications }}</div>
<div class="stat-txt">Postulaciones</div>
</div>
<div class="stat">
<div class="stat-num">
{{ state.eligibility.isEligibleToApply ? "Sí" : "No" }}
</div>
<div class="stat-txt">Apto</div>
</div>
</div>
</div>
</div>
</div>
</a-card>
</a-badge-ribbon>
<!-- KPIs secundarios (más limpios) -->
<a-row :gutter="[16, 16]">
<a-col :xs="24" :md="12">
<a-card class="soft-card" title="Resumen de postulaciones">
<div class="kpi-row">
<div class="kpi">
<div class="kpi-label">Veces que postuló</div>
<div class="kpi-value">{{ totalApplications }}</div>
</div>
<div class="kpi-tags">
<a-tag v-for="(v, k) in applicationsByStatus" :key="k">
{{ k }}: <b>{{ v }}</b>
</a-tag>
<a-tag v-if="!Object.keys(applicationsByStatus).length">Sin registros</a-tag>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :md="12">
<a-card class="soft-card" title="Estado del postulante">
<a-descriptions :column="1" size="small" bordered>
<a-descriptions-item label="Aptitud">
<a-tag :color="eligibilityTag.color" class="pill-tag">{{ eligibilityTag.text }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="Test">
<a-tag :color="testStatusUi.color" class="pill-tag">{{ testStatusUi.text }}</a-tag>
</a-descriptions-item>
</a-descriptions>
</a-card>
</a-col>
</a-row>
<!-- Procesos disponibles -->
<a-card class="soft-card" title="Procesos disponibles">
<a-table
:columns="processColumns"
:data-source="state.availableProcesses"
row-key="id"
:pagination="{ pageSize: 6 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'ABIERTO' ? 'green' : record.status === 'PRONTO' ? 'blue' : 'default'">
{{ record.status }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button @click="onViewProcess(record)">Ver</a-button>
<a-button
type="primary"
:disabled="!record.canApply || !state.eligibility.isEligibleToApply"
@click="onApply(record)"
>
Postular
</a-button>
</a-space>
<div v-if="!state.eligibility.isEligibleToApply" class="mini-help">
Debes estar apto para postular.
</div>
</template>
</template>
</a-table>
</a-card>
<!-- Historial -->
<a-card class="soft-card" title="Historial de postulaciones">
<a-list
:data-source="state.applications"
:locale="{ emptyText: 'Aún no tienes postulaciones registradas.' }"
>
<template #renderItem="{ item }">
<a-list-item class="list-item">
<a-list-item-meta>
<template #title>
<div class="list-title">
<span class="list-title-text">{{ item.processName }}</span>
<a-tag>{{ item.status }}</a-tag>
</div>
</template>
<template #description>
<span class="muted">Postuló el:</span> {{ item.createdAt }}
</template>
</a-list-item-meta>
</a-list-item>
</template>
</a-list>
</a-card>
</a-space>
</a-spin>
</template>
.page {
width: 100%;
padding: 12px;
}
<style scoped>
/* Cards */
.soft-card {
.pageCard {
max-width: 1120px;
margin: 0 auto;
border-radius: 14px;
}
.pill-tag {
border-radius: 999px;
padding: 4px 10px;
font-weight: 600;
}
/* Header */
.header-row {
.topbar {
display: flex;
justify-content: space-between;
gap: 16px;
gap: 14px;
flex-wrap: wrap;
align-items: center;
align-items: flex-start;
}
.h-title {
font-size: 18px;
font-weight: 700;
.hello {
font-size: 24px;
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
}
.h-sub {
opacity: 0.75;
.sub {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.header-actions {
.topbarRight {
display: flex;
align-items: center;
flex-direction: column;
gap: 10px;
align-items: flex-end;
min-width: 260px;
}
/* TEST HERO */
.test-hero {
border-radius: 18px;
overflow: hidden;
background: linear-gradient(135deg, rgba(24, 144, 255, 0.14), rgba(82, 196, 26, 0.10));
}
.test-grid {
display: grid;
grid-template-columns: 1.4fr 0.8fr;
gap: 16px;
.statusLine {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
@media (max-width: 992px) {
.test-grid {
grid-template-columns: 1fr;
}
.label {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.test-kicker {
font-weight: 700;
letter-spacing: 0.2px;
opacity: 0.85;
.btnTop {
min-width: 180px;
}
.test-title {
font-size: 26px;
font-weight: 800;
margin-top: 6px;
line-height: 1.15;
/* TEST destacado (sin degradado) */
.testBox {
border: 2px solid var(--ant-colorPrimary, #1677ff);
background: var(--ant-colorBgContainer, #fff);
border-radius: 12px;
padding: 16px;
}
.test-meta {
margin-top: 12px;
.testHead {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap; /* ✅ clave para móvil */
align-items: flex-start;
}
.test-dates {
margin-top: 10px;
display: grid;
gap: 6px;
.testTitle {
font-size: 20px;
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
}
.muted {
opacity: 0.75;
.testSub {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
line-height: 1.4;
}
/* CTA box */
.test-cta {
.testHeadRight {
min-width: 260px;
display: flex;
align-items: stretch;
flex-direction: column;
gap: 6px;
align-items: flex-end;
}
.cta-box {
width: 100%;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 16px;
padding: 16px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.06);
.btnTest {
height: 44px;
border-radius: 10px;
font-weight: 800;
}
.cta-label {
font-weight: 700;
opacity: 0.85;
margin-bottom: 10px;
.testInfoGrid {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.cta-btn {
height: 44px;
border-radius: 12px;
.infoItem {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: var(--ant-colorFillAlter, #fafafa);
border-radius: 10px;
padding: 10px 12px;
min-width: 0;
}
.infoK {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.infoV {
margin-top: 4px;
font-weight: 800;
color: var(--ant-colorText, #111827);
word-break: break-word;
}
.cta-hint {
margin-top: 10px;
.strong { font-weight: 900; }
.warn { color: var(--ant-colorTextHeading, #111827); }
.hintInline {
margin-left: 6px;
font-size: 12px;
opacity: 0.75;
line-height: 1.35;
color: var(--ant-colorTextSecondary, #6b7280);
}
.reasons {
margin: 8px 0 0 18px;
line-height: 1.7;
}
.summaryBox {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: var(--ant-colorFillAlter, #fafafa);
border-radius: 12px;
padding: 14px;
}
.summaryTitle {
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
margin-bottom: 10px;
}
.cta-stats {
.summaryGrid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr 2fr;
gap: 12px;
}
.stat {
border-radius: 12px;
padding: 10px 12px;
background: rgba(0, 0, 0, 0.02);
border: 1px solid rgba(0, 0, 0, 0.05);
.summaryK {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.stat-num {
font-size: 18px;
.summaryV {
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
}
.stat-txt {
font-size: 12px;
opacity: 0.75;
.chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* KPI */
.kpi-row {
display: grid;
gap: 10px;
.chip {
border-radius: 999px;
}
.kpi {
/* Secciones */
.sectionHead {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
}
.kpi-label {
opacity: 0.75;
.sectionTitle {
font-size: 20px;
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
}
.kpi-value {
font-size: 26px;
font-weight: 900;
.sectionSub {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.kpi-tags {
display: flex;
/* Tabla: evita romper en móvil */
.tableWrap {
width: 100%;
overflow-x: auto;
}
:deep(.ant-table-container) {
overflow-x: auto;
}
/* Acciones: en móvil se apilan */
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
flex-wrap: wrap;
}
.microHelp {
margin-top: 6px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
/* List */
.list-item {
border-radius: 12px;
.listItem {
padding-left: 0;
padding-right: 0;
}
.list-title {
.listTitle {
display: flex;
justify-content: space-between;
gap: 12px;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.list-title-text {
.listName {
font-weight: 700;
}
.mini-help {
margin-top: 6px;
font-size: 12px;
opacity: 0.7;
.muted {
color: var(--ant-colorTextSecondary, #6b7280);
}
.mt12 { margin-top: 12px; }
/* ✅ Breakpoints reales */
@media (max-width: 768px) {
.page { padding: 8px; }
.topbarRight {
width: 100%;
min-width: 0;
align-items: stretch;
}
.btnTop {
width: 100%;
min-width: 0;
}
.testHeadRight {
width: 100%;
min-width: 0;
align-items: stretch;
}
.testInfoGrid {
grid-template-columns: 1fr; /* ✅ cards info en columna */
}
.summaryGrid {
grid-template-columns: 1fr; /* ✅ resumen en columna */
}
.actions {
grid-template-columns: 1fr; /* ✅ botones uno debajo del otro */
}
}
@media (max-width: 480px) {
.testBox { padding: 12px; }
.testTitle { font-size: 16px; }
}
</style>

@ -1,222 +1,389 @@
<template>
<a-card class="procesos-card" :bordered="false">
<a-card class="card" :bordered="true">
<template #title>
<div class="card-title">
<div class="title-left">
<div class="title-main">Mis procesos de admisión</div>
<div class="title-sub">Resultados registrados por DNI</div>
<div class="header">
<div class="headerLeft">
<div class="title">Mis procesos de admisión</div>
<div class="subtitle">Resultados registrados por DNI</div>
</div>
<div class="title-right">
<a-space>
<a-button @click="obtenerProcesos" :loading="loading">Actualizar</a-button>
</a-space>
<div class="headerRight">
<a-button @click="obtenerProcesos" :loading="loading" class="btn" block>
Actualizar
</a-button>
</div>
</div>
</template>
<a-spin :spinning="loading">
<div class="top-summary">
<a-alert v-if="!loading" type="info" show-icon class="summary-alert">
Total de procesos: <strong>{{ procesos.length }}</strong>
</a-alert>
<!-- Top tools -->
<div class="tools">
<div class="counter">
<span class="counterLabel">Total</span>
<span class="counterValue">{{ procesosFiltrados.length }}</span>
</div>
<a-input
v-model:value="search"
allow-clear
placeholder="Buscar por nombre de proceso..."
class="search-input"
placeholder="Buscar por nombre de proceso"
class="search"
/>
</div>
<a-table
class="procesos-table"
:dataSource="procesosFiltrados"
:columns="columns"
rowKey="id"
:pagination="{ pageSize: 7, showSizeChanger: false }"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<!-- Nombre -->
<template v-if="column.key === 'nombre'">
<div class="nombre">
{{ record.nombre || '-' }}
</div>
</template>
<!-- Desktop/tablet: tabla -->
<div v-if="!isMobile" class="tableWrap">
<a-table
class="table"
:dataSource="procesosFiltrados"
:columns="columns"
rowKey="id"
:pagination="{ pageSize: 7, showSizeChanger: false }"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'nombre'">
<div class="nombre">{{ record.nombre || "-" }}</div>
<div class="meta">ID: {{ record.id }}</div>
</template>
<!-- Puntaje -->
<template v-else-if="column.key === 'puntaje'">
<span class="puntaje">
{{ record.puntaje ?? '-' }}
</span>
</template>
<template v-else-if="column.key === 'puntaje'">
<div class="puntaje">{{ record.puntaje ?? "-" }}</div>
<div class="meta">Puntaje</div>
</template>
<template v-else-if="column.key === 'apto'">
<span class="statusPill" :class="statusClass(record.apto)">
{{ aptoTexto(record.apto) }}
</span>
</template>
<!-- Apto -->
<template v-else-if="column.key === 'apto'">
<a-tag :color="aptoColor(record.apto)" class="tag-pill">
{{ aptoTexto(record.apto) }}
</a-tag>
<template v-else-if="column.key === 'acciones'">
<a-space>
<a-button size="small" @click="verDetalle(record)">Ver detalle</a-button>
</a-space>
</template>
</template>
<!-- Acciones -->
<template v-else-if="column.key === 'acciones'">
<a-space>
<a-button size="small" @click="verDetalle(record)">Ver detalle</a-button>
</a-space>
<template #emptyText>
<a-empty description="No se encontraron procesos" />
</template>
</template>
</a-table>
</div>
<!-- Mobile: cards -->
<div v-else class="cards">
<template v-if="procesosFiltrados.length">
<div v-for="p in procesosFiltrados" :key="p.id" class="itemCard">
<div class="itemTop">
<div class="itemTitle">{{ p.nombre || "-" }}</div>
<span class="statusPill" :class="statusClass(p.apto)">
{{ aptoTexto(p.apto) }}
</span>
</div>
<template #emptyText>
<a-empty description="No se encontraron procesos" />
<div class="itemGrid">
<div class="kv">
<div class="k">Puntaje</div>
<div class="v strong">{{ p.puntaje ?? "-" }}</div>
</div>
<div class="kv">
<div class="k">ID</div>
<div class="v">{{ p.id }}</div>
</div>
</div>
<div class="itemActions">
<a-button type="primary" class="btnPrimary" block @click="verDetalle(p)">
Ver detalle
</a-button>
</div>
</div>
</template>
</a-table>
<a-empty v-else description="No se encontraron procesos" />
</div>
</a-spin>
</a-card>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import api from '../../axiosPostulante' // ajusta la ruta a tu axios
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
import { message } from "ant-design-vue";
import api from "../../axiosPostulante";
const procesos = ref([])
const loading = ref(false)
const search = ref('')
const procesos = ref([]);
const loading = ref(false);
const search = ref("");
const columns = [
{ title: 'Proceso', dataIndex: 'nombre', key: 'nombre', width: 420 },
{ title: 'Puntaje', dataIndex: 'puntaje', key: 'puntaje', width: 140 },
{ title: 'Estado', dataIndex: 'apto', key: 'apto', width: 160 },
{ title: 'Acciones', key: 'acciones', width: 160 }
]
{ title: "Proceso", dataIndex: "nombre", key: "nombre", width: 420 },
{ title: "Puntaje", dataIndex: "puntaje", key: "puntaje", width: 140 },
{ title: "Estado", dataIndex: "apto", key: "apto", width: 160 },
{ title: "Acciones", key: "acciones", width: 160 },
];
const obtenerProcesos = async () => {
loading.value = true
loading.value = true;
try {
// Ruta: crea una ruta GET que apunte a misProcesos()
// Ejemplo: Route::get('/postulante/mis-procesos', ...)
const { data } = await api.get('/postulante/mis-procesos')
const { data } = await api.get("/postulante/mis-procesos");
if (data?.success) {
procesos.value = Array.isArray(data.data) ? data.data : []
procesos.value = Array.isArray(data.data) ? data.data : [];
} else {
message.error('No se pudieron obtener los procesos')
message.error("No se pudieron obtener los procesos");
}
} catch (e) {
console.error(e)
message.error(e.response?.data?.message || 'Error al cargar procesos')
console.error(e);
message.error(e.response?.data?.message || "Error al cargar procesos");
} finally {
loading.value = false
loading.value = false;
}
}
};
const aptoTexto = (apto) => {
// en DB puede venir 1/0, true/false, "1"/"0"
if (apto === 1 || apto === true || apto === '1') return 'APTO'
if (apto === 0 || apto === false || apto === '0') return 'NO APTO'
return String(apto ?? '-').toUpperCase()
}
if (apto === 1 || apto === true || apto === "1") return "APTO";
if (apto === 0 || apto === false || apto === "0") return "NO APTO";
return String(apto ?? "-").toUpperCase();
};
const aptoColor = (apto) => {
if (apto === 1 || apto === true || apto === '1') return 'green'
if (apto === 0 || apto === false || apto === '0') return 'red'
return 'default'
}
/** ✅ Un solo color: no usamos verde/rojo; solo estilos neutros + primary sutil */
const statusClass = (apto) => {
if (apto === 1 || apto === true || apto === "1") return "ok";
if (apto === 0 || apto === false || apto === "0") return "bad";
return "neutral";
};
const procesosFiltrados = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return procesos.value
return procesos.value.filter(p =>
String(p.nombre || '').toLowerCase().includes(q)
)
})
const q = search.value.trim().toLowerCase();
if (!q) return procesos.value;
return procesos.value.filter((p) => String(p.nombre || "").toLowerCase().includes(q));
});
const verDetalle = (record) => {
// Aquí puedes navegar a otra vista si tienes ruta
// router.push({ name: 'DetalleProceso', params: { procesoId: record.id } })
message.info(`Proceso: ${record.nombre} | Puntaje: ${record.puntaje ?? '-'}`)
message.info(`Proceso: ${record.nombre} | Puntaje: ${record.puntaje ?? "-"}`);
};
/* ✅ Responsive real: detecta móvil para cambiar a cards */
const isMobile = ref(false);
let mq = null;
function setMobile() {
isMobile.value = window.matchMedia("(max-width: 640px)").matches;
}
onMounted(() => {
obtenerProcesos()
})
obtenerProcesos();
mq = window.matchMedia("(max-width: 640px)");
setMobile();
// addEventListener es lo moderno; fallback por compatibilidad
if (mq.addEventListener) mq.addEventListener("change", setMobile);
else mq.addListener(setMobile);
});
onBeforeUnmount(() => {
if (!mq) return;
if (mq.removeEventListener) mq.removeEventListener("change", setMobile);
else mq.removeListener(setMobile);
});
</script>
<style scoped>
.procesos-card {
/* =========================
Base (formal 17+, sin degradados)
1 color acento: primary
========================= */
.card {
max-width: 1100px;
margin: 20px auto;
border-radius: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.10);
background: #fff;
margin: 16px auto;
border-radius: 14px;
}
.card-title {
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
}
.headerLeft {
min-width: 240px;
}
.title {
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
font-size: 16px;
}
.subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.headerRight {
min-width: 180px;
display: flex;
justify-content: flex-end;
}
.btn {
border-radius: 10px;
}
.title-left {
/* Tools */
.tools {
display: grid;
grid-template-columns: 220px 1fr;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.counter {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.08));
background: var(--ant-colorFillAlter, #fafafa);
border-radius: 12px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
justify-content: space-between;
align-items: baseline;
}
.counterLabel {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
font-weight: 700;
}
.counterValue {
font-size: 18px;
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
}
.search {
border-radius: 12px;
}
.title-main {
/* Table */
.tableWrap {
border-radius: 12px;
overflow: hidden;
}
.table :deep(.ant-table) {
border-radius: 12px;
overflow: hidden;
}
.nombre {
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
}
.puntaje {
font-weight: 900;
font-size: 18px;
color: #111827;
color: var(--ant-colorPrimary, #1677ff); /* ✅ único acento */
}
.meta {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
margin-top: 2px;
}
.title-sub {
font-weight: 650;
color: #6b7280;
font-size: 13px;
/* Status pill (sin verde/rojo) */
.statusPill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-weight: 900;
font-size: 12px;
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.08));
background: var(--ant-colorFillAlter, #fafafa);
color: var(--ant-colorTextHeading, #111827);
}
.statusPill.ok {
border-color: rgba(22,119,255,.35);
background: rgba(22,119,255,.08);
}
.statusPill.bad {
border-color: rgba(0,0,0,.10);
background: rgba(0,0,0,.04);
}
.statusPill.neutral {
opacity: .85;
}
.top-summary {
display: flex;
/* Mobile cards */
.cards {
display: grid;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 14px;
}
.summary-alert {
.itemCard {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.08));
background: var(--ant-colorBgContainer, #fff);
border-radius: 14px;
margin: 0;
flex: 1;
min-width: 280px;
padding: 12px;
}
.search-input {
max-width: 360px;
border-radius: 12px;
.itemTop {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
flex-wrap: wrap;
}
.nombre {
font-weight: 850;
color: #111827;
.itemTitle {
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
line-height: 1.2;
}
.puntaje {
.itemGrid {
margin-top: 10px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.kv {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: var(--ant-colorFillAlter, #fafafa);
border-radius: 12px;
padding: 10px 12px;
}
.k {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
font-weight: 700;
}
.v {
margin-top: 4px;
font-weight: 900;
color: #1677ff;
color: var(--ant-colorTextHeading, #111827);
}
.v.strong {
color: var(--ant-colorPrimary, #1677ff);
}
.tag-pill {
border-radius: 999px;
font-weight: 800;
padding: 2px 10px;
.itemActions {
margin-top: 12px;
}
.btnPrimary {
height: 42px;
border-radius: 12px;
font-weight: 900;
}
.procesos-table :deep(.ant-table) {
border-radius: 14px;
overflow: hidden;
/* Responsive tools */
@media (max-width: 640px) {
.card {
margin: 0;
border-radius: 0;
}
.headerRight {
width: 100%;
min-width: 0;
}
.tools {
grid-template-columns: 1fr;
}
.itemGrid {
grid-template-columns: 1fr;
}
}
</style>
</style>

@ -1,202 +1,190 @@
<template>
<a-card class="pagos-card" :bordered="false">
<a-card class="card" :bordered="true">
<template #title>
<div class="card-title">
<div class="title-left">
<div class="title-main">Mis Pagos Realizados</div>
<div class="title-sub">Historial de pagos registrados en el sistema</div>
<div class="header">
<div class="headerLeft">
<div class="title">Mis pagos realizados</div>
<div class="subtitle">Historial de pagos registrados en el sistema</div>
</div>
<div class="title-right">
<a-space>
<a-button @click="obtenerPagos" :loading="loading">Actualizar</a-button>
</a-space>
<div class="headerRight">
<a-button @click="obtenerPagos" :loading="loading" class="btn" block>
Actualizar
</a-button>
</div>
</div>
</template>
<!-- Loading -->
<a-spin :spinning="loading">
<!-- Resumen superior -->
<div class="top-summary">
<a-alert type="info" show-icon class="summary-alert">
Total de pagos encontrados: <strong>{{ pagos.length }}</strong>
</a-alert>
<!-- Tools -->
<div class="tools">
<div class="counter">
<span class="counterLabel">Total</span>
<span class="counterValue">{{ pagosFiltrados.length }}</span>
</div>
<a-input
v-model:value="search"
allow-clear
placeholder="Buscar por código, uso/proceso o tipo..."
class="search-input"
placeholder="Buscar por código, uso/proceso o tipo"
class="search"
/>
</div>
<!-- Tabla -->
<a-table
class="pagos-table"
:dataSource="pagosFiltrados"
:columns="columns"
rowKey="key"
:pagination="{ pageSize: 6, showSizeChanger: false }"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<!-- Tipo -->
<template v-if="column.key === 'tipo'">
<a-tag :color="getColor(record.tipo)" class="tag-pill">
{{ tipoLabel(record.tipo) }}
</a-tag>
<!-- Desktop/tablet: tabla -->
<div v-if="!isMobile" class="tableWrap">
<a-table
class="table"
:dataSource="pagosFiltrados"
:columns="columns"
rowKey="key"
:pagination="{ pageSize: 6, showSizeChanger: false }"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'tipo'">
<span class="pill pill--neutral">{{ tipoLabel(record.tipo) }}</span>
</template>
<template v-else-if="column.key === 'codigo'">
<div class="codigoCell">
<div class="codigo">{{ record.codigo || "-" }}</div>
<span v-if="record.estado" class="pill pill--soft">{{ record.estado }}</span>
</div>
</template>
<template v-else-if="column.key === 'uso'">
<div class="usoCell">
<div class="usoMain">{{ getUso(record) }}</div>
<div v-if="record.proceso_nombre || record.proceso" class="usoSub">
{{ record.proceso_nombre || record.proceso }}
</div>
</div>
</template>
<template v-else-if="column.key === 'monto'">
<div class="monto">S/ {{ Number(record.monto || 0).toFixed(2) }}</div>
</template>
<template v-else-if="column.key === 'fecha_pago'">
<div class="fecha">{{ formatFecha(record.fecha_pago) }}</div>
</template>
</template>
<!-- Código -->
<template v-else-if="column.key === 'codigo'">
<div class="codigo-cell">
<span class="codigo">{{ record.codigo || '-' }}</span>
<a-tag v-if="record.estado" color="default" class="tag-mini">
{{ record.estado }}
</a-tag>
</div>
<template #emptyText>
<a-empty description="No se encontraron pagos" />
</template>
</a-table>
</div>
<!-- Uso / Proceso -->
<template v-else-if="column.key === 'uso'">
<div class="uso-cell">
<div class="uso-main">
{{ getUso(record) }}
</div>
<div v-if="record.proceso_nombre || record.proceso" class="uso-sub">
{{ record.proceso_nombre || record.proceso }}
<!-- Mobile: cards -->
<div v-else class="cards">
<template v-if="pagosFiltrados.length">
<div v-for="p in pagosFiltrados" :key="p.key" class="itemCard">
<div class="itemTop">
<div class="itemLeft">
<div class="itemTitle">{{ getUso(p) }}</div>
<div class="itemSub" v-if="p.proceso_nombre || p.proceso">
{{ p.proceso_nombre || p.proceso }}
</div>
</div>
<span class="pill pill--neutral">{{ tipoLabel(p.tipo) }}</span>
</div>
</template>
<!-- Monto -->
<template v-else-if="column.key === 'monto'">
<span class="monto">
S/ {{ Number(record.monto || 0).toFixed(2) }}
</span>
</template>
<div class="grid">
<div class="kv">
<div class="k">Código</div>
<div class="v strong">{{ p.codigo || "-" }}</div>
</div>
<!-- Fecha -->
<template v-else-if="column.key === 'fecha_pago'">
<span class="fecha">{{ formatFecha(record.fecha_pago) }}</span>
</template>
</template>
<div class="kv">
<div class="k">Monto</div>
<div class="v strong primary">S/ {{ Number(p.monto || 0).toFixed(2) }}</div>
</div>
<!-- Empty -->
<template #emptyText>
<a-empty description="No se encontraron pagos" />
<div class="kv">
<div class="k">Fecha</div>
<div class="v">{{ formatFecha(p.fecha_pago) }}</div>
</div>
<div class="kv" v-if="p.estado">
<div class="k">Estado</div>
<div class="v">
<span class="pill pill--soft">{{ p.estado }}</span>
</div>
</div>
</div>
</div>
</template>
</a-table>
<!-- Tip informativo -->
<a-alert
class="tip-alert"
type="warning"
show-icon
message="Nota"
description="Si un pago no muestra el campo “Uso / Proceso”, es porque aún no está asociado a un proceso en el backend."
/>
<a-empty v-else description="No se encontraron pagos" />
</div>
<!-- Nota (sin colores fuertes) -->
<div class="note">
<div class="noteTitle">Nota</div>
<div class="noteText">
Si un pago no muestra Uso / Proceso, es porque aún no está asociado a un proceso en el backend.
</div>
</div>
</a-spin>
</a-card>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { message } from 'ant-design-vue'
import api from '.././../axiosPostulante'
import { ref, onMounted, computed, onBeforeUnmount } from "vue";
import { message } from "ant-design-vue";
import api from ".././../axiosPostulante";
const pagos = ref([])
const loading = ref(false)
const search = ref('')
const pagos = ref([]);
const loading = ref(false);
const search = ref("");
const columns = [
{
title: 'Tipo',
dataIndex: 'tipo',
key: 'tipo',
width: 140
},
{
title: 'Código',
dataIndex: 'codigo',
key: 'codigo',
width: 200
},
{
title: 'Uso / Proceso',
dataIndex: 'uso',
key: 'uso',
width: 320
},
{
title: 'Monto',
dataIndex: 'monto',
key: 'monto',
width: 140
},
{
title: 'Fecha de Pago',
dataIndex: 'fecha_pago',
key: 'fecha_pago',
width: 220
}
]
{ title: "Tipo", dataIndex: "tipo", key: "tipo", width: 160 },
{ title: "Código", dataIndex: "codigo", key: "codigo", width: 220 },
{ title: "Uso / Proceso", dataIndex: "uso", key: "uso", width: 360 },
{ title: "Monto", dataIndex: "monto", key: "monto", width: 160 },
{ title: "Fecha de pago", dataIndex: "fecha_pago", key: "fecha_pago", width: 220 },
];
const obtenerPagos = async () => {
loading.value = true
loading.value = true;
try {
const { data } = await api.get('/postulante/pagos')
const { data } = await api.get("/postulante/pagos");
if (data.success) {
pagos.value = (data.pagos || []).map((pago, index) => ({
...pago,
key: pago.id ?? `${pago.codigo ?? 'pago'}-${index}` // key estable
}))
key: pago.id ?? `${pago.codigo ?? "pago"}-${index}`,
}));
} else {
message.error('No se pudieron obtener los pagos')
message.error("No se pudieron obtener los pagos");
}
} catch (error) {
console.error(error)
message.error('Error al cargar pagos')
console.error(error);
message.error("Error al cargar pagos");
} finally {
loading.value = false
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 tipoLabel = (tipo) => {
const map = {
pyto_peru: 'PYTO PERÚ',
banco_nacion: 'BANCO NACIÓN',
caja: 'CAJA'
}
return map[tipo] || String(tipo || '-').toUpperCase()
}
pyto_peru: "PYTO PERÚ",
banco_nacion: "BANCO NACIÓN",
caja: "CAJA",
};
return map[tipo] || String(tipo || "-").toUpperCase();
};
const formatFecha = (fecha) => {
if (!fecha) return '-'
const soloFecha = String(fecha).split(' ')[0] // "2025-01-27"
const [y, m, d] = soloFecha.split('-')
return `${Number(d)}/${Number(m)}/${y}` // "27/1/2025"
}
/**
* Uso / Proceso
* Ajusta los nombres según lo que tu backend mande:
* - uso
* - concepto
* - motivo
* - descripcion
* - proceso_nombre
* - proceso
*/
if (!fecha) return "-";
const soloFecha = String(fecha).split(" ")[0];
const [y, m, d] = soloFecha.split("-");
return `${Number(d)}/${Number(m)}/${y}`;
};
const getUso = (record) => {
return (
record.uso ||
@ -205,149 +193,282 @@ const getUso = (record) => {
record.descripcion ||
record.proceso_nombre ||
record.proceso ||
'-'
)
}
"-"
);
};
const pagosFiltrados = computed(() => {
const q = search.value.trim().toLowerCase()
if (!q) return pagos.value
const q = search.value.trim().toLowerCase();
if (!q) return pagos.value;
return pagos.value.filter((p) => {
const texto = [
p.tipo,
p.codigo,
getUso(p),
p.proceso_nombre,
p.proceso
]
const texto = [p.tipo, p.codigo, getUso(p), p.proceso_nombre, p.proceso]
.filter(Boolean)
.join(' ')
.toLowerCase()
return texto.includes(q)
})
})
.join(" ")
.toLowerCase();
return texto.includes(q);
});
});
/* ✅ Responsive real: móvil = cards */
const isMobile = ref(false);
let mq = null;
function setMobile() {
isMobile.value = window.matchMedia("(max-width: 640px)").matches;
}
onMounted(() => {
obtenerPagos()
})
obtenerPagos();
mq = window.matchMedia("(max-width: 640px)");
setMobile();
if (mq.addEventListener) mq.addEventListener("change", setMobile);
else mq.addListener(setMobile);
});
onBeforeUnmount(() => {
if (!mq) return;
if (mq.removeEventListener) mq.removeEventListener("change", setMobile);
else mq.removeListener(setMobile);
});
</script>
<style scoped>
.pagos-card {
/* Formal 17+ | sin degradados | un solo acento: Primary */
.card {
max-width: 1100px;
margin: 20px auto;
border-radius: 18px;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.10);
background: #fff;
margin: 16px auto;
border-radius: 14px;
}
.card-title {
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
}
.title-left {
display: flex;
flex-direction: column;
gap: 4px;
.headerLeft {
min-width: 240px;
}
.title-main {
.title {
font-weight: 900;
font-size: 18px;
color: #111827;
color: var(--ant-colorTextHeading, #111827);
font-size: 16px;
}
.title-sub {
font-weight: 650;
color: #6b7280;
font-size: 13px;
.subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.top-summary {
.headerRight {
min-width: 180px;
display: flex;
justify-content: flex-end;
}
.btn {
border-radius: 10px;
}
/* Tools */
.tools {
display: grid;
grid-template-columns: 220px 1fr;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.counter {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.08));
background: var(--ant-colorFillAlter, #fafafa);
border-radius: 12px;
padding: 10px 12px;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 14px;
align-items: baseline;
}
.summary-alert {
border-radius: 14px;
margin: 0;
flex: 1;
min-width: 280px;
.counterLabel {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
font-weight: 800;
}
.search-input {
max-width: 360px;
.counterValue {
font-size: 18px;
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
}
.search {
border-radius: 12px;
}
.pagos-table :deep(.ant-table) {
border-radius: 14px;
/* Table */
.tableWrap {
border-radius: 12px;
overflow: hidden;
}
.tag-pill {
border-radius: 999px;
font-weight: 800;
padding: 2px 10px;
.table :deep(.ant-table) {
border-radius: 12px;
overflow: hidden;
}
.tag-mini {
/* Pills (neutras, sin verde/azul/naranja por tipo) */
.pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-weight: 700;
font-weight: 900;
font-size: 12px;
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.08));
background: var(--ant-colorFillAlter, #fafafa);
color: var(--ant-colorTextHeading, #111827);
white-space: nowrap;
}
.pill--neutral {
border-color: rgba(0,0,0,.10);
background: rgba(0,0,0,.04);
}
.pill--soft {
border-color: rgba(22,119,255,.25);
background: rgba(22,119,255,.08); /* ✅ único acento */
color: var(--ant-colorTextHeading, #111827);
}
.codigo-cell {
/* Cells */
.codigoCell {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.codigo {
font-weight: 900;
color: #111827;
color: var(--ant-colorTextHeading, #111827);
}
.uso-cell {
.usoCell {
display: flex;
flex-direction: column;
gap: 2px;
}
.usoMain {
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
line-height: 1.25;
}
.usoSub {
font-weight: 700;
color: var(--ant-colorTextSecondary, #6b7280);
font-size: 12px;
}
.uso-main {
.monto {
font-weight: 900;
color: var(--ant-colorPrimary, #1677ff); /* ✅ único acento */
}
.fecha {
font-weight: 800;
color: #111827;
line-height: 1.3;
color: var(--ant-colorText, #374151);
}
.uso-sub {
font-weight: 650;
color: #6b7280;
font-size: 12px;
/* Mobile cards */
.cards {
display: grid;
gap: 12px;
}
.monto {
.itemCard {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.08));
background: var(--ant-colorBgContainer, #fff);
border-radius: 14px;
padding: 12px;
}
.itemTop {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
flex-wrap: wrap;
}
.itemTitle {
font-weight: 900;
color: #1677ff;
color: var(--ant-colorTextHeading, #111827);
line-height: 1.2;
}
.itemSub {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.fecha {
font-weight: 650;
color: #374151;
.grid {
margin-top: 10px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.kv {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: var(--ant-colorFillAlter, #fafafa);
border-radius: 12px;
padding: 10px 12px;
}
.k {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
font-weight: 800;
}
.v {
margin-top: 4px;
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
}
.v.strong {
font-weight: 900;
}
.v.primary {
color: var(--ant-colorPrimary, #1677ff);
}
.tip-alert {
/* Nota */
.note {
margin-top: 14px;
border-radius: 14px;
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.08));
background: var(--ant-colorFillAlter, #fafafa);
padding: 12px;
}
.noteTitle {
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
}
.noteText {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
line-height: 1.5;
}
/* Responsive */
@media (max-width: 640px) {
.card {
margin: 0;
border-radius: 0;
}
.headerRight {
width: 100%;
min-width: 0;
}
.tools {
grid-template-columns: 1fr;
}
.grid {
grid-template-columns: 1fr;
}
}
</style>
</style>

File diff suppressed because it is too large Load Diff

@ -7,11 +7,25 @@ import { message } from "ant-design-vue";
const router = useRouter();
const examenStore = useExamenStore();
/** =========================
* UI State
* ========================= */
const showModal = ref(false);
const iniciandoExamen = ref(false);
const creandoExamen = ref(false);
const formRef = ref();
const formRef = ref(null);
/**
* SEPARA LOADINGS:
* - loadingPage: solo para cargar data inicial de la página (no debe tumbar modales)
* - loadingAreas: solo para cargar áreas cuando cambias de proceso (se usa en el select)
*/
const loadingPage = ref(false);
const loadingAreas = ref(false);
/** =========================
* Form
* ========================= */
const formState = reactive({
proceso_id: undefined,
area_proceso_id: undefined,
@ -19,7 +33,9 @@ const formState = reactive({
codigo_pago: "",
});
/* ===== Modal Voucher ===== */
/** =========================
* Modal Voucher
* ========================= */
const secuenciaModalOpen = ref(false);
const secuenciaTipo = ref("caja");
@ -50,28 +66,23 @@ const openSecuencia = (tipo) => {
const modalBodyStyle = computed(() => ({ maxHeight: "72vh", overflowY: "auto" }));
/* ===== Datos ===== */
/** =========================
* Datos (computed)
* ========================= */
const hasExamen = computed(() => !!examenStore.examenActual);
const procesoNombre = computed(() => examenStore.examenActual?.proceso?.nombre || "No asignado");
const areaNombre = computed(() => examenStore.examenActual?.area?.nombre || "No seleccionada");
const intentosActuales = computed(() => examenStore.examenActual?.intentos || 0);
const intentosMax = computed(() => examenStore.examenActual?.intentos_max || 1);
const yaDioTest = computed(() => intentosActuales.value > 0);
/** ✅ Formal + agradable: dejamos el Primary solo para el CTA */
const estadoTexto = computed(() => {
if (!hasExamen.value) return "Aún sin asignar";
return yaDioTest.value ? "Completado" : "Listo para iniciar";
});
const estadoBadgeStatus = computed(() => {
if (!hasExamen.value) return "default";
return yaDioTest.value ? "success" : "processing";
});
/* ===== Progreso / Mensajes ===== */
/** Progreso */
const stepCurrent = computed(() => {
if (!hasExamen.value) return 0;
return yaDioTest.value ? 2 : 1;
@ -94,7 +105,7 @@ const estadoAlertDesc = computed(() => {
: "Son 10 preguntas. Al finalizar verás tu resultado al instante.";
});
/* ===== CTA único (sin duplicar botones) ===== */
/** CTA */
const primaryAction = computed(() => {
if (!hasExamen.value) {
return { label: "Seleccionar área", type: "primary", loading: false, onClick: () => (showModal.value = true) };
@ -105,13 +116,20 @@ const primaryAction = computed(() => {
return { label: "Ver resultados", type: "primary", loading: false, onClick: verResultado };
});
/* ===== Options ===== */
/** Options */
const procesoOptions = computed(() =>
examenStore.procesos.map((p) => ({ value: p.id, label: p.nombre, requiere_pago: p.requiere_pago }))
(examenStore.procesos || []).map((p) => ({
value: p.id,
label: p.nombre,
requiere_pago: p.requiere_pago,
}))
);
const areaOptions = computed(() =>
examenStore.areas.map((a) => ({ value: a.area_proceso_id, label: a.nombre }))
(examenStore.areas || []).map((a) => ({
value: a.area_proceso_id,
label: a.nombre,
}))
);
const tipoPagoOptions = [
@ -120,12 +138,22 @@ const tipoPagoOptions = [
{ value: "caja", label: "Caja" },
];
/**
* IMPORTANTE:
* La condición de pago debe funcionar aunque venga 1/"1"/true/"true"
*/
const procesoRequierePago = computed(() => {
const proceso = examenStore.procesos.find((p) => p.id === formState.proceso_id);
return proceso?.requiere_pago === 1;
const pid = formState.proceso_id;
if (pid === undefined || pid === null || pid === "") return false;
const proceso = (examenStore.procesos || []).find((p) => String(p.id) === String(pid));
if (!proceso) return false;
const v = proceso.requiere_pago;
return v === 1 || v === "1" || v === true || v === "true";
});
/* ===== Validación ===== */
/** Validación (condicional) */
const rules = {
proceso_id: [{ required: true, message: "Selecciona un proceso", trigger: "change" }],
area_proceso_id: [{ required: true, message: "Selecciona un área", trigger: "change" }],
@ -151,15 +179,44 @@ const rules = {
],
};
/* ===== Actions ===== */
/** =========================
* Actions
* ========================= */
const refrescar = async () => {
await examenStore.fetchExamenActual();
message.success("Actualizado");
try {
await examenStore.fetchExamenActual();
message.success("Actualizado");
} catch {
message.error("No se pudo actualizar");
}
};
/**
* SOLO una forma de cargar áreas: por @change
* (NO uses watch(proceso_id) + @change a la vez)
*/
const handleProcesoChange = async (procesoId) => {
// reset dependientes
formState.area_proceso_id = undefined;
if (procesoId) await examenStore.fetchAreas(procesoId);
// reset pago cuando cambia proceso
formState.tipo_pago = undefined;
formState.codigo_pago = "";
// limpiar validaciones relacionadas
formRef.value?.clearValidate?.(["area_proceso_id", "tipo_pago", "codigo_pago"]);
if (!procesoId) {
examenStore.areas = [];
return;
}
loadingAreas.value = true;
try {
await examenStore.fetchAreas(procesoId);
} finally {
loadingAreas.value = false;
}
};
const crearExamen = async () => {
@ -170,21 +227,30 @@ const crearExamen = async () => {
try {
creandoExamen.value = true;
// valida con reglas condicionales
if (formRef.value) await formRef.value.validate();
// si requiere pago, se manda en plano al store (payload final lo arma el store)
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) {
if (result?.success) {
message.success("Área asignada");
showModal.value = false;
resetModal();
await examenStore.fetchExamenActual();
} else {
message.error(result.message || "No se pudo asignar el área");
// si backend responde errors, intenta mostrarlo
const msg =
result?.message ||
(result?.errors
? Object.values(result.errors).flat().join(" ")
: "No se pudo asignar el área");
message.error(msg);
}
} catch (e) {
message.error("No se pudo asignar el área");
@ -237,102 +303,151 @@ const resetModal = () => {
formState.area_proceso_id = undefined;
formState.tipo_pago = undefined;
formState.codigo_pago = "";
if (formRef.value) formRef.value.clearValidate?.();
examenStore.areas = [];
formRef.value?.clearValidate?.();
};
/* ===== Lifecycle ===== */
onMounted(async () => {
await examenStore.fetchProcesos();
await examenStore.fetchExamenActual();
/**
* Solo resetea cuando CIERRAS modal (no cuando cambia proceso)
* IMPORTANTE: usamos watch(showModal) y NO tocamos showModal desde otros watchers
*/
watch(showModal, (open) => {
if (!open) resetModal();
});
watch(showModal, (newVal) => {
if (!newVal) {
examenStore.areas = [];
resetModal();
/** Lifecycle */
onMounted(async () => {
loadingPage.value = true;
try {
await examenStore.fetchProcesos();
await examenStore.fetchExamenActual();
} finally {
loadingPage.value = false;
}
});
</script>
<template>
<!-- 1 solo Card contenedor. Sin degradados. Formal y amigable. -->
<a-card :loading="examenStore.cargando" class="page-card" :bordered="true">
<!-- OJO: loading del card SOLO usa loadingPage, NO uses examenStore.cargando -->
<a-card :loading="loadingPage" class="pageCard" :bordered="true">
<!-- Header -->
<template #title>
<div class="header">
<div class="header__left">
<div class="header__titleRow">
<span class="header__title">Test diagnóstico</span>
<a-badge :status="estadoBadgeStatus" :text="estadoTexto" />
<div class="topbar">
<div class="topbarLeft">
<div class="titleRow">
<span class="title">Test diagnóstico</span>
<span class="statusPill">{{ estadoTexto }}</span>
</div>
<div class="header__subtitle">
Es una guía para que midas tu nivel. <b>No afecta</b> tu admisión.
<div class="subtitle">
Guía referencial para medir tu nivel. <b>No afecta</b> tu admisión.
</div>
</div>
<div class="header__right">
<a-button @click="refrescar">Actualizar</a-button>
<div class="topbarRight">
<a-button @click="refrescar" class="btnTop" block>Actualizar</a-button>
</div>
</div>
</template>
<!-- 1 bloque principal (sin cards anidados) -->
<div class="content">
<!-- Mensaje principal -->
<a-alert :type="estadoAlertType" show-icon :message="estadoAlertMessage" :description="estadoAlertDesc" />
<a-divider />
<!-- Resumen amigable (rápido de leer) -->
<div class="subhead">Resumen rápido</div>
<a-descriptions size="small" bordered :column="{ xs: 1, sm: 2, md: 3 }">
<a-descriptions-item label="Proceso">{{ procesoNombre }}</a-descriptions-item>
<a-descriptions-item label="Área">{{ areaNombre }}</a-descriptions-item>
<a-descriptions-item label="Intentos">{{ intentosActuales }} / {{ intentosMax }}</a-descriptions-item>
<a-descriptions-item label="Preguntas">10</a-descriptions-item>
<a-descriptions-item label="Duración">10 min aprox.</a-descriptions-item>
<a-descriptions-item label="Resultado">Al instante</a-descriptions-item>
</a-descriptions>
<!-- ÚNICO CTA -->
<div class="ctaRow">
<a-button
:type="primaryAction.type"
size="large"
:loading="primaryAction.loading"
@click="primaryAction.onClick"
class="ctaBtn"
>
{{ primaryAction.label }}
</a-button>
<!-- Link discreto (solo si aplica) -->
<a-button v-if="procesoRequierePago" type="link" @click="openSecuencia('bn')">
¿Dónde veo mi secuencia?
</a-button>
<!-- HERO -->
<section class="hero">
<div class="heroLeft">
<div class="heroKicker">Tu evaluación</div>
<div class="heroTitle">Aquí está tu test</div>
<div class="heroText">10 preguntas 10 min aprox. Resultado inmediato</div>
<div class="heroFacts">
<div class="fact">
<div class="factK">Proceso</div>
<div class="factV">{{ procesoNombre }}</div>
</div>
<div class="fact">
<div class="factK">Área</div>
<div class="factV">{{ areaNombre }}</div>
</div>
<div class="fact">
<div class="factK">Intentos</div>
<div class="factV">{{ intentosActuales }} / {{ intentosMax }}</div>
</div>
</div>
<a-alert
class="mt12"
:type="estadoAlertType"
show-icon
:message="estadoAlertMessage"
:description="estadoAlertDesc"
/>
</div>
<a-divider />
<div class="heroRight">
<div class="ctaCard">
<div class="ctaTitle">Acción</div>
<div class="ctaHint">
<span v-if="!hasExamen">Primero selecciona tu área.</span>
<span v-else-if="hasExamen && !yaDioTest">Inicia cuando estés listo.</span>
<span v-else>Puedes ver tus resultados.</span>
</div>
<!-- Progreso -->
<div class="subhead">Tu camino</div>
<a-steps :current="stepCurrent" size="small">
<a-button
:type="primaryAction.type"
size="large"
:loading="primaryAction.loading"
@click="primaryAction.onClick"
class="ctaBtn"
block
>
{{ primaryAction.label }}
</a-button>
<a-button v-if="procesoRequierePago" type="link" class="ctaLink" @click="openSecuencia('bn')">
¿Dónde veo mi secuencia?
</a-button>
<a-divider class="ctaDivider" />
<div class="miniGrid">
<div class="mini">
<div class="miniK">Estado</div>
<div class="miniV">{{ estadoTexto }}</div>
</div>
<div class="mini">
<div class="miniK">Consejo</div>
<div class="miniV">Responde con calma.</div>
</div>
</div>
</div>
</div>
</section>
<a-divider />
<!-- Progreso -->
<section class="section">
<div class="sectionTitle">Tu camino</div>
<div class="sectionSub">Paso a paso, sin complicaciones.</div>
<a-steps :current="stepCurrent" size="small" class="mt12">
<a-step title="Selecciona área" description="Elige proceso y área." />
<a-step title="Resuelve el test" description="10 preguntas." />
<a-step title="Mira tu resultado" description="Recomendaciones." />
</a-steps>
<div class="hint">
<div class="hint mt12">
<span v-if="!hasExamen">Tip: selecciona un área y luego podrás iniciar.</span>
<span v-else-if="hasExamen && !yaDioTest">Tip: responde tranquilo, sin apuro.</span>
<span v-else>Tip: puedes volver a ver tus resultados cuando quieras.</span>
</div>
</section>
<a-divider />
<a-divider />
<!-- FAQ -->
<section class="section">
<div class="sectionTitle">Preguntas frecuentes</div>
<div class="sectionSub">Respuestas cortas y claras.</div>
<!-- FAQ corto -->
<a-collapse accordion>
<a-collapse accordion class="mt12">
<a-collapse-panel key="1" header="¿Para qué sirve este test?">
<ul class="bullets">
<li>Te ayuda a medir tu preparación antes del examen.</li>
@ -356,7 +471,7 @@ watch(showModal, (newVal) => {
</a-space>
</a-collapse-panel>
</a-collapse>
</div>
</section>
<!-- ================== MODAL SECUENCIA ================== -->
<a-modal
@ -372,7 +487,7 @@ watch(showModal, (newVal) => {
<a-col :xs="24" :md="14">
<a-card :bordered="true">
<div class="voucher-caption">
Busca el campo <b>Secuencia</b> o <b>N° operación</b> (según el comprobante).
Busca el campo <b>Secuencia</b> o <b>N° operación</b>.
</div>
<div class="voucher-img-wrap">
@ -385,9 +500,9 @@ watch(showModal, (newVal) => {
<a-col :xs="24" :md="10">
<a-card :bordered="true">
<div class="subhead">¿Qué hago?</div>
<div class="sectionTitle">¿Qué hago?</div>
<ol class="ordered">
<ol class="ordered mt12">
<li>Ubica el número de secuencia en tu voucher/boleta.</li>
<li>Ingresa ese número cuando el sistema lo pida.</li>
<li>Si pagaste hoy y no aparece, espera el tiempo de habilitación.</li>
@ -412,7 +527,8 @@ watch(showModal, (newVal) => {
title="Seleccionar área"
:mask-closable="false"
width="640px"
@cancel="resetModal"
@cancel="showModal = false"
class="select-modal"
>
<a-alert
v-if="hasExamen"
@ -440,14 +556,19 @@ watch(showModal, (newVal) => {
placeholder="Selecciona un área"
:options="areaOptions"
:disabled="!formState.proceso_id"
:loading="examenStore.cargando"
:loading="loadingAreas"
/>
</a-form-item>
<!-- CAMPOS DE PAGO: aparecen sin cerrar modal -->
<div v-if="procesoRequierePago" class="pay-block">
<a-alert message="Este proceso requiere pago" type="info" show-icon class="mb12" />
<a-form-item label="Tipo de Pago" name="tipo_pago">
<a-select v-model:value="formState.tipo_pago" placeholder="Selecciona tipo de pago" :options="tipoPagoOptions" />
<a-select
v-model:value="formState.tipo_pago"
placeholder="Selecciona tipo de pago"
:options="tipoPagoOptions"
/>
</a-form-item>
<a-form-item label="Código de Pago" name="codigo_pago">
<a-input v-model:value="formState.codigo_pago" placeholder="Escribe el código de pago" />
@ -468,82 +589,207 @@ watch(showModal, (newVal) => {
</template>
<style scoped>
/* Sin degradados, formal, y agradable:
- Más aire
- Mensajes cortos
- 1 CTA principal
- Tipos legibles
*/
.page-card {
/* =========================
Base (formal, 17+, sin degradados)
========================= */
.pageCard {
max-width: 1100px;
margin: 0 auto;
border-radius: 14px;
}
.mt12 { margin-top: 12px; }
.mb12 { margin-bottom: 12px; }
/* Header */
.header {
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
}
.header__titleRow {
.titleRow {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
align-items: center;
}
.header__title {
font-weight: 700;
.title {
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
}
.header__subtitle {
.statusPill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.08));
background: var(--ant-colorFillAlter, #fafafa);
font-size: 12px;
font-weight: 700;
color: var(--ant-colorText, #111827);
}
.subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
line-height: 1.35;
}
.topbarRight {
min-width: 220px;
display: flex;
justify-content: flex-end;
}
.btnTop {
border-radius: 10px;
}
/* HERO (sin degradado) */
.hero {
border: 2px solid var(--ant-colorPrimary, #1677ff);
border-radius: 14px;
padding: 16px;
background: var(--ant-colorBgContainer, #fff);
display: grid;
grid-template-columns: 1.4fr 0.9fr;
gap: 14px;
}
.heroKicker {
font-size: 12px;
letter-spacing: 0.2px;
color: var(--ant-colorTextSecondary, #6b7280);
font-weight: 700;
}
.heroTitle {
margin-top: 4px;
font-size: 20px;
line-height: 1.15;
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
}
.content {
padding: 4px 0;
.heroText {
margin-top: 6px;
font-size: 13px;
color: var(--ant-colorText, #374151);
}
.subhead {
font-weight: 600;
margin-bottom: 10px;
.heroFacts {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.fact {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: var(--ant-colorFillAlter, #fafafa);
border-radius: 12px;
padding: 10px 12px;
min-width: 0;
}
.factK {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.factV {
margin-top: 4px;
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* CTA row */
.ctaRow {
margin-top: 14px;
/* CTA card */
.ctaCard {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: var(--ant-colorFillAlter, #fafafa);
border-radius: 14px;
padding: 14px;
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.ctaTitle {
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
}
.ctaHint {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
line-height: 1.35;
}
.ctaBtn {
height: 40px;
border-radius: 10px; /* un poco más friendly */
font-weight: 600;
height: 44px;
border-radius: 12px;
font-weight: 900;
}
/* Hint */
.hint {
margin-top: 10px;
.ctaLink {
padding: 0;
height: auto;
text-align: left;
}
.ctaDivider {
margin: 10px 0 0;
}
.miniGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.mini {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: #fff;
border-radius: 12px;
padding: 10px 12px;
min-width: 0;
}
.miniK {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.miniV {
margin-top: 4px;
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
}
/* Sections */
.sectionTitle {
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
}
.sectionSub {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
/* Lists */
.bullets {
margin: 0 0 0 18px;
line-height: 1.7;
.hint {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.ordered {
.bullets, .ordered {
margin: 0 0 0 18px;
line-height: 1.7;
}
@ -557,9 +803,9 @@ watch(showModal, (newVal) => {
.voucher-img-wrap {
background: var(--ant-colorFillAlter, #fafafa);
border: 1px solid var(--ant-colorBorderSecondary, rgba(0, 0, 0, 0.06));
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
padding: 10px;
border-radius: 10px;
border-radius: 12px;
}
.modal-actions {
@ -573,24 +819,38 @@ watch(showModal, (newVal) => {
.pay-block {
margin-top: 8px;
padding: 12px;
border: 1px solid var(--ant-colorBorderSecondary, rgba(0, 0, 0, 0.06));
border-radius: 10px;
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
border-radius: 12px;
background: var(--ant-colorFillAlter, #fafafa);
}
.mb12 {
margin-bottom: 12px;
}
.mt12 {
margin-top: 12px;
/* Responsive */
@media (max-width: 992px) {
.hero {
grid-template-columns: 1fr;
}
.heroFacts {
grid-template-columns: 1fr;
}
.miniGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 576px) {
.page-card {
margin: 0;
}
.ctaBtn {
width: 100%;
}
.pageCard { margin: 0; }
.topbarRight { width: 100%; min-width: 0; }
.btnTop { width: 100%; }
}
/* Modales no se salen en móvil */
:deep(.voucher-modal .ant-modal),
:deep(.select-modal .ant-modal) {
max-width: 92vw;
}
:deep(.voucher-modal .ant-modal-content),
:deep(.select-modal .ant-modal-content) {
border-radius: 14px;
}
</style>

Loading…
Cancel
Save