Merge branch 'main' into docker-prod

main
commit 195d50de7f

@ -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,209 @@
<?php
namespace App\Http\Controllers\Administracion;
use App\Http\Controllers\Controller;
use App\Models\Noticia;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class NoticiaController extends Controller
{
// GET /api/noticias
public function index(Request $request)
{
$perPage = (int) $request->get('per_page', 9);
$query = Noticia::query();
if ($request->filled('publicado')) {
$query->where('publicado', $request->boolean('publicado'));
}
if ($request->filled('categoria')) {
$query->where('categoria', (string) $request->get('categoria'));
}
if ($request->filled('q')) {
$q = trim((string) $request->get('q'));
$query->where(function ($sub) use ($q) {
$sub->where('titulo', 'like', "%{$q}%")
->orWhere('descripcion_corta', 'like', "%{$q}%");
});
}
$data = $query
->orderByDesc('destacado')
->orderByDesc('fecha_publicacion')
->orderByDesc('orden')
->orderByDesc('id')
->paginate($perPage);
return response()->json([
'success' => true,
'data' => $data->items(), // incluye imagen_url por el accessor/appends
'meta' => [
'current_page' => $data->currentPage(),
'last_page' => $data->lastPage(),
'per_page' => $data->perPage(),
'total' => $data->total(),
],
]);
}
// GET /api/noticias/{noticia}
public function show(Noticia $noticia)
{
return response()->json([
'success' => true,
'data' => $noticia, // incluye imagen_url por el accessor/appends
]);
}
// GET /api/noticias-publicas/{noticia} (o la ruta que uses)
public function showPublic(Noticia $noticia)
{
abort_unless($noticia->publicado, 404);
return response()->json([
'success' => true,
'data' => $noticia,
]);
}
// POST /api/noticias (multipart/form-data si viene imagen)
public function store(Request $request)
{
$data = $request->validate([
'titulo' => ['required', 'string', 'max:220'],
'slug' => ['nullable', 'string', 'max:260', 'unique:noticias,slug'],
'descripcion_corta' => ['nullable', 'string', 'max:500'],
'contenido' => ['nullable', 'string'],
'categoria' => ['nullable', 'string', 'max:80'],
'tag_color' => ['nullable', 'string', 'max:30'],
// ✅ dos formas de imagen
'imagen' => ['nullable', 'file', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
'imagen_url' => ['nullable', 'url', 'max:600'],
'link_url' => ['nullable', 'url', 'max:600'],
'link_texto' => ['nullable', 'string', 'max:120'],
'fecha_publicacion' => ['nullable', 'date'],
'publicado' => ['nullable', 'boolean'],
'destacado' => ['nullable', 'boolean'],
'orden' => ['nullable', 'integer'],
]);
// slug por defecto (igual tu modelo lo genera, pero aquí lo dejamos por consistencia)
if (empty($data['slug'])) {
$data['slug'] = Str::slug($data['titulo']);
}
// si viene archivo, manda a storage y prioriza archivo
if ($request->hasFile('imagen')) {
$path = $request->file('imagen')->store('noticias', 'public');
$data['imagen_path'] = $path;
$data['imagen_url'] = null; // ✅ evita conflicto con url externa
} else {
// si viene imagen_url externa, no debe haber imagen_path
$data['imagen_path'] = null;
}
// si publican sin fecha, poner ahora
if (!empty($data['publicado']) && empty($data['fecha_publicacion'])) {
$data['fecha_publicacion'] = now();
}
$noticia = Noticia::create($data);
return response()->json([
'success' => true,
'data' => $noticia->fresh(),
], 201);
}
// PUT/PATCH /api/noticias/{noticia}
public function update(Request $request, Noticia $noticia)
{
$data = $request->validate([
'titulo' => ['sometimes', 'required', 'string', 'max:220'],
'slug' => ['sometimes', 'nullable', 'string', 'max:260', 'unique:noticias,slug,' . $noticia->id],
'descripcion_corta' => ['sometimes', 'nullable', 'string', 'max:500'],
'contenido' => ['sometimes', 'nullable', 'string'],
'categoria' => ['sometimes', 'nullable', 'string', 'max:80'],
'tag_color' => ['sometimes', 'nullable', 'string', 'max:30'],
// ✅ dos formas de imagen
'imagen' => ['sometimes', 'nullable', 'file', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
'imagen_url' => ['sometimes', 'nullable', 'url', 'max:600'],
'link_url' => ['sometimes', 'nullable', 'url', 'max:600'],
'link_texto' => ['sometimes', 'nullable', 'string', 'max:120'],
'fecha_publicacion' => ['sometimes', 'nullable', 'date'],
'publicado' => ['sometimes', 'boolean'],
'destacado' => ['sometimes', 'boolean'],
'orden' => ['sometimes', 'integer'],
]);
// Si llega imagen archivo, reemplaza la anterior y limpia imagen_url externa
if ($request->hasFile('imagen')) {
if ($noticia->imagen_path && Storage::disk('public')->exists($noticia->imagen_path)) {
Storage::disk('public')->delete($noticia->imagen_path);
}
$path = $request->file('imagen')->store('noticias', 'public');
$data['imagen_path'] = $path;
$data['imagen_url'] = null; // ✅ prioridad archivo
}
// Si llega imagen_url (externa), borra la imagen física anterior y limpia imagen_path
if (array_key_exists('imagen_url', $data) && !empty($data['imagen_url'])) {
if ($noticia->imagen_path && Storage::disk('public')->exists($noticia->imagen_path)) {
Storage::disk('public')->delete($noticia->imagen_path);
}
$data['imagen_path'] = null;
}
// Si explícitamente mandan imagen_url = null (quitar url) y no mandan archivo,
// no tocamos imagen_path (se queda como está). Si quieres que también limpie path,
// dime y lo cambiamos.
// si se marca publicado y no hay fecha, set now
if (
array_key_exists('publicado', $data) &&
$data['publicado'] &&
empty($noticia->fecha_publicacion) &&
empty($data['fecha_publicacion'])
) {
$data['fecha_publicacion'] = now();
}
// si cambian titulo y slug no vino, regenerar slug (opcional)
if (array_key_exists('titulo', $data) && !array_key_exists('slug', $data)) {
$data['slug'] = Str::slug($data['titulo']);
}
$noticia->update($data);
return response()->json([
'success' => true,
'data' => $noticia->fresh(),
]);
}
// DELETE /api/noticias/{noticia}
public function destroy(Noticia $noticia)
{
if ($noticia->imagen_path && Storage::disk('public')->exists($noticia->imagen_path)) {
Storage::disk('public')->delete($noticia->imagen_path);
}
$noticia->delete();
return response()->json([
'success' => true,
'message' => 'Noticia eliminada correctamente',
]);
}
}

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

@ -30,6 +30,22 @@ class ProcesoAdmisionDetalleController extends Controller
{
ProcesoAdmision::findOrFail($procesoId);
// ✅ Convertir JSON string -> array antes de validar (cuando llega desde FormData)
if ($request->has('listas') && is_string($request->input('listas'))) {
$decoded = json_decode($request->input('listas'), true);
if (json_last_error() === JSON_ERROR_NONE) {
$request->merge(['listas' => $decoded]);
}
}
if ($request->has('meta') && is_string($request->input('meta'))) {
$decoded = json_decode($request->input('meta'), true);
if (json_last_error() === JSON_ERROR_NONE) {
$request->merge(['meta' => $decoded]);
}
}
$data = $request->validate([
'tipo' => ['required', Rule::in(['requisitos','pagos','vacantes','cronograma'])],
'titulo_detalle' => ['required','string','max:255'],
@ -69,6 +85,22 @@ class ProcesoAdmisionDetalleController extends Controller
{
$detalle = ProcesoAdmisionDetalle::findOrFail($id);
// ✅ Convertir JSON string -> array antes de validar (cuando llega desde FormData)
if ($request->has('listas') && is_string($request->input('listas'))) {
$decoded = json_decode($request->input('listas'), true);
if (json_last_error() === JSON_ERROR_NONE) {
$request->merge(['listas' => $decoded]);
}
}
if ($request->has('meta') && is_string($request->input('meta'))) {
$decoded = json_decode($request->input('meta'), true);
if (json_last_error() === JSON_ERROR_NONE) {
$request->merge(['meta' => $decoded]);
}
}
$data = $request->validate([
'tipo' => ['sometimes', Rule::in(['requisitos','pagos','vacantes','cronograma'])],
'titulo_detalle' => ['sometimes','string','max:255'],

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

@ -4,5 +4,5 @@ namespace App\Http\Controllers;
abstract class Controller
{
//
}

@ -21,6 +21,7 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Http;
use App\Services\ExamenService;
use Illuminate\Support\Facades\DB;
class ExamenController extends Controller
{
@ -431,7 +432,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'])
@ -454,19 +455,34 @@ public function iniciarExamen(Request $request)
}
public function finalizarExamen($examenId)
{
$examen = Examen::findOrFail($examenId);
$postulante = request()->user();
if ($examen->postulante_id !== $postulante->id) {
// Validar que el examen le pertenezca al postulante autenticado
if ((int) $examen->postulante_id !== (int) $postulante->id) {
return response()->json([
'success' => false,
'message' => 'No autorizado'
], 403);
}
$this->examenService->finalizarExamen($examen);
// (Opcional) Evitar finalizar 2 veces
if ($examen->estado === 'finalizado') {
return response()->json([
'success' => false,
'message' => 'El examen ya está finalizado'
], 409);
}
// Finalizar examen
$examen->update([
'estado' => 'finalizado',
'hora_fin' => now(),
]);
return response()->json([
'success' => true,
@ -475,4 +491,256 @@ public function iniciarExamen(Request $request)
}
public function calificarExamen($examenId, Request $request)
{
$postulante = $request->user();
if (!$postulante) {
return response()->json(['success' => false, 'mensaje' => 'No autenticado.'], 401);
}
return DB::transaction(function () use ($examenId, $postulante) {
// 1) Validar examen del postulante
$examen = DB::table('examenes')
->where('id', $examenId)
->where('postulante_id', $postulante->id)
->first();
if (!$examen) {
return response()->json([
'success' => false,
'mensaje' => 'No se encontró un examen para este postulante.'
], 404);
}
// 2) Si ya está calificado, devolver existente (y opcionalmente recalcular orden)
$existente = DB::table('resultados_examenes')
->where('examen_id', $examen->id)
->first();
if ($existente) {
// Obtener proceso_id para poder recalcular si deseas (opcional)
$procesoId = DB::table('area_proceso')
->where('id', $examen->area_proceso_id)
->value('proceso_id');
if ($procesoId) $this->recalcularOrdenMerito($procesoId);
return response()->json([
'success' => true,
'mensaje' => 'Examen ya calificado.',
'total_puntos' => (float)$existente->total_puntos,
'total_correctas' => (int)$existente->total_correctas,
'total_incorrectas' => (int)$existente->total_incorrectas,
'total_nulas' => (int)$existente->total_nulas,
'porcentaje_correctas' => (float)$existente->porcentaje_correctas,
'calificacion_sobre_20' => (float)$existente->calificacion_sobre_20,
'orden_merito' => $existente->orden_merito,
'correctas_por_curso' => json_decode($existente->correctas_por_curso, true),
'incorrectas_por_curso' => json_decode($existente->incorrectas_por_curso ?? '[]', true),
'preguntas_totales_por_curso' => json_decode($existente->preguntas_totales_por_curso ?? '[]', true),
]);
}
// 3) Obtener configuración de calificación desde proceso -> calificaciones
$cfg = DB::table('area_proceso as ap')
->join('procesos as pr', 'pr.id', '=', 'ap.proceso_id')
->join('calificaciones as ca', 'ca.id', '=', 'pr.calificacion_id')
->where('ap.id', $examen->area_proceso_id)
->select(
'pr.id as proceso_id',
'ca.puntos_correcta',
'ca.puntos_incorrecta',
'ca.puntos_nula',
'ca.puntaje_maximo'
)
->first();
if (!$cfg) {
return response()->json([
'success' => false,
'mensaje' => 'No se ha definido un tipo de calificación para este proceso.'
], 400);
}
$puntosCorrecta = (float) $cfg->puntos_correcta;
$puntosIncorrecta = (float) $cfg->puntos_incorrecta;
$puntosNula = (float) $cfg->puntos_nula;
$puntajeMaximo = (float) $cfg->puntaje_maximo;
// 4) Traer preguntas asignadas con su pregunta y curso
$items = DB::table('preguntas_asignadas as pa')
->join('preguntas as p', 'p.id', '=', 'pa.pregunta_id')
->join('cursos as c', 'c.id', '=', 'p.curso_id')
->where('pa.examen_id', $examen->id)
->select(
'pa.id as pa_id',
'pa.estado as pa_estado',
'pa.respuesta_usuario',
'p.respuesta_correcta',
'c.nombre as curso_nombre'
)
->get();
if ($items->isEmpty()) {
return response()->json([
'success' => false,
'mensaje' => 'El examen no tiene preguntas asignadas.'
], 422);
}
// 5) Calificar y actualizar cada pregunta_asignada
$totalPuntos = 0.0;
$totalCorrectas = 0;
$totalIncorrectas = 0;
$totalNulas = 0;
$correctasPorCurso = [];
$incorrectasPorCurso = [];
$preguntasTotalesPorCurso = [];
foreach ($items as $row) {
$curso = $row->curso_nombre;
$preguntasTotalesPorCurso[$curso] = ($preguntasTotalesPorCurso[$curso] ?? 0) + 1;
$correctasPorCurso[$curso] = $correctasPorCurso[$curso] ?? 0;
$incorrectasPorCurso[$curso] = $incorrectasPorCurso[$curso] ?? 0;
$nuevoEsCorrecta = 2; // 2 = blanco
$nuevoPuntaje = $puntosNula; // nula/blanco
if ($row->pa_estado === 'anulada') {
// anulada => nula
$nuevoEsCorrecta = 2;
$nuevoPuntaje = $puntosNula;
$totalNulas++;
} else {
$ru = trim((string) $row->respuesta_usuario);
$rc = trim((string) $row->respuesta_correcta);
if ($ru === '') {
// blanco
$nuevoEsCorrecta = 2;
$nuevoPuntaje = $puntosNula;
$totalNulas++;
} else {
$ruN = mb_strtoupper($ru);
$rcN = mb_strtoupper($rc);
if ($rcN !== '' && $ruN === $rcN) {
$nuevoEsCorrecta = 1;
$nuevoPuntaje = $puntosCorrecta;
$totalCorrectas++;
$correctasPorCurso[$curso]++;
} else {
$nuevoEsCorrecta = 0;
$nuevoPuntaje = $puntosIncorrecta;
$totalIncorrectas++;
$incorrectasPorCurso[$curso]++;
}
}
}
$totalPuntos += (float) $nuevoPuntaje;
DB::table('preguntas_asignadas')
->where('id', $row->pa_id)
->update([
'es_correcta' => $nuevoEsCorrecta,
'puntaje' => $nuevoPuntaje,
'updated_at' => now(),
]);
}
// 6) Resumen
$totalPreguntas = (int) $items->count();
$porcentajeCorrectas = $totalPreguntas > 0 ? ($totalCorrectas / $totalPreguntas) * 100 : 0;
$calificacionSobre20 = ($puntajeMaximo > 0)
? ($totalPuntos / $puntajeMaximo) * 20
: 0;
$correctasPorCursoFormato = [];
foreach ($correctasPorCurso as $curso => $corr) {
$y = $preguntasTotalesPorCurso[$curso] ?? 0;
$correctasPorCursoFormato[$curso] = "{$corr} de {$y}";
}
// 7) Guardar resultado en resultados_examenes
$resultadoId = DB::table('resultados_examenes')->insertGetId([
'postulante_id' => $postulante->id,
'examen_id' => $examen->id,
'total_puntos' => round($totalPuntos, 3),
'correctas_por_curso' => json_encode($correctasPorCursoFormato),
'incorrectas_por_curso' => json_encode($incorrectasPorCurso),
'preguntas_totales_por_curso' => json_encode($preguntasTotalesPorCurso),
'total_correctas' => $totalCorrectas,
'total_incorrectas' => $totalIncorrectas,
'total_nulas' => $totalNulas,
'porcentaje_correctas' => round($porcentajeCorrectas, 2),
'calificacion_sobre_20' => round($calificacionSobre20, 2),
'created_at' => now(),
'updated_at' => now(),
]);
// 8) Recalcular orden de mérito por proceso
$this->recalcularOrdenMerito($cfg->proceso_id);
// 9) Leer orden_merito ya asignado (opcional)
$orden = DB::table('resultados_examenes')->where('id', $resultadoId)->value('orden_merito');
DB::table('examenes')->where('id', $examen->id)->update([
'estado' => 'calificado',
'hora_fin' => now(),
]);
return response()->json([
'success' => true,
'mensaje' => 'Examen calificado exitosamente.',
'examen_id' => $examen->id,
'proceso_id' => $cfg->proceso_id,
'total_puntos' => round($totalPuntos, 2),
'total_correctas' => $totalCorrectas,
'total_incorrectas' => $totalIncorrectas,
'total_nulas' => $totalNulas,
'porcentaje_correctas' => round($porcentajeCorrectas, 2),
'calificacion_sobre_20' => round($calificacionSobre20, 2),
'orden_merito' => $orden,
'correctas_por_curso' => $correctasPorCursoFormato,
'incorrectas_por_curso' => $incorrectasPorCurso,
'preguntas_totales_por_curso' => $preguntasTotalesPorCurso,
]);
});
}
public function recalcularOrdenMerito($procesoId): void
{
DB::statement("
UPDATE resultados_examenes r
JOIN (
SELECT
r2.id,
ROW_NUMBER() OVER (
ORDER BY
r2.total_puntos DESC,
COALESCE(r2.updated_at, r2.created_at) ASC
) AS nuevo_orden
FROM resultados_examenes r2
JOIN examenes e ON e.id = r2.examen_id
JOIN area_proceso ap ON ap.id = e.area_proceso_id
WHERE ap.proceso_id = ?
) x ON x.id = r.id
SET r.orden_merito = x.nuevo_orden
", [$procesoId]);
}
}

@ -161,28 +161,28 @@ class PostulanteAuthController extends Controller
$pagos = [];
// ===============================
// 1⃣ PAGOS PYTO PERÚ
// ===============================
$urlPyto = "https://service2.unap.edu.pe/PAYMENTS_MNG/v1/{$dni}/8/";
$responsePyto = Http::get($urlPyto);
if ($responsePyto->successful()) {
$dataPyto = $responsePyto->json();
if (!empty($dataPyto['data'])) {
foreach ($dataPyto['data'] as $pago) {
$pagos[] = [
'tipo' => 'pyto_peru',
'codigo' => $pago['autorizationCode'] ?? null,
'monto' => $pago['total'] ?? null,
'fecha_pago' => $pago['confirmedDate'] ?? null,
'estado' => true,
'raw' => $pago // devuelve toda la info original
];
}
}
}
// // ===============================
// // 1⃣ PAGOS PYTO PERÚ
// // ===============================
// $urlPyto = "https://service2.unap.edu.pe/PAYMENTS_MNG/v1/{$dni}/8/";
// $responsePyto = Http::get($urlPyto);
// if ($responsePyto->successful()) {
// $dataPyto = $responsePyto->json();
// if (!empty($dataPyto['data'])) {
// foreach ($dataPyto['data'] as $pago) {
// $pagos[] = [
// 'tipo' => 'pyto_peru',
// 'codigo' => $pago['autorizationCode'] ?? null,
// 'monto' => $pago['total'] ?? null,
// 'fecha_pago' => $pago['confirmedDate'] ?? null,
// 'estado' => true,
// 'raw' => $pago // devuelve toda la info original
// ];
// }
// }
// }
// ===============================
// 2⃣ PAGOS CAJA

@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Models\ProcesoAdmision;
use App\Models\ProcesoAdmisionDetalle;
class WebController extends Controller
{
public function GetProcesoAdmision()
{
$procesos = ProcesoAdmision::select(
'id',
'titulo',
'subtitulo',
'descripcion',
'slug',
'tipo_proceso',
'modalidad',
'publicado',
'fecha_publicacion',
'fecha_inicio_preinscripcion',
'fecha_fin_preinscripcion',
'fecha_inicio_inscripcion',
'fecha_fin_inscripcion',
'fecha_examen1',
'fecha_examen2',
'fecha_resultados',
'fecha_inicio_biometrico',
'fecha_fin_biometrico',
'imagen_path',
'banner_path',
'brochure_path',
'link_preinscripcion',
'link_inscripcion',
'link_resultados',
'link_reglamento',
'estado',
'created_at',
'updated_at'
)
->with([
'detalles' => function ($query) {
$query->select(
'id',
'proceso_admision_id',
'tipo',
'titulo_detalle',
'descripcion',
'listas',
'meta',
'url',
'imagen_path',
'imagen_path_2',
'created_at',
'updated_at'
);
}
])
->latest() // 🔥 Esto ordena por created_at DESC
->get();
return response()->json([
'success' => true,
'data' => $procesos
]);
}
}

@ -19,7 +19,14 @@ class Examen extends Model
'pago_id',
'intentos',
'hora_inicio',
'estado',
'hora_fin',
];
protected $casts = [
'pagado' => 'boolean',
'hora_inicio' => 'datetime',
'hora_fin' => 'datetime',
];
public function postulante()

@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class Noticia extends Model
{
use SoftDeletes;
protected $table = 'noticias';
protected $fillable = [
'titulo',
'slug',
'descripcion_corta',
'contenido',
'categoria',
'tag_color',
'imagen_path',
'imagen_url', // ✅ agrega esto si también lo guardas en BD
'link_url',
'link_texto',
'fecha_publicacion',
'publicado',
'destacado',
'orden',
];
protected $casts = [
'fecha_publicacion' => 'datetime',
'publicado' => 'boolean',
'destacado' => 'boolean',
'orden' => 'integer',
];
// ✅ se incluirá en el JSON
protected $appends = ['imagen_url'];
public function getImagenUrlAttribute(): ?string
{
// 1) Si en BD hay una URL externa, úsala
if (!empty($this->attributes['imagen_url'])) {
return $this->attributes['imagen_url'];
}
// 2) Si hay imagen en storage, genera URL absoluta
if (!empty($this->imagen_path)) {
return url(Storage::disk('public')->url($this->imagen_path));
}
return null;
}
protected static function booted(): void
{
static::saving(function (Noticia $noticia) {
if (!$noticia->slug) {
$noticia->slug = Str::slug($noticia->titulo);
}
});
}
}

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

@ -65,5 +65,7 @@ class ProcesoAdmision extends Model
{
return $this->hasMany(ResultadoAdmision::class, 'idproceso');
}
}

@ -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,17 +115,36 @@ public function obtenerPreguntasExamen(Examen $examen): array
public function guardarRespuesta(PreguntaAsignada $pa, string $respuesta): array
{
public function guardarRespuesta(PreguntaAsignada $pa, ?string $respuesta): array
{
if ($pa->estado === 'respondida') {
return ['success' => false, 'message' => 'Ya respondida'];
}
// 🔹 Si está en blanco
if (empty($respuesta)) {
$pa->update([
'respuesta_usuario' => null,
'es_correcta' => 2, // 2 = blanco
'puntaje_obtenido' => 0,
'estado' => 'respondida',
'respondida_at' => now()
]);
return [
'success' => true,
'correcta' => 2,
'puntaje' => 0
];
}
// 🔹 Si respondió algo
$esCorrecta = $respuesta === $pa->pregunta->respuesta_correcta;
$pa->update([
'respuesta_usuario' => $respuesta,
'es_correcta' => $esCorrecta,
'es_correcta' => $esCorrecta ? 1 : 0,
'puntaje_obtenido' => $esCorrecta ? $pa->puntaje_base : 0,
'estado' => 'respondida',
'respondida_at' => now()
@ -133,21 +152,12 @@ public function obtenerPreguntasExamen(Examen $examen): array
return [
'success' => true,
'correcta' => $esCorrecta,
'correcta' => $esCorrecta ? 1 : 0,
'puntaje' => $pa->puntaje_obtenido
];
}
}
/**
* Finalizar examen
*/
public function finalizarExamen(Examen $examen): void
{
$examen->update([
'estado' => 'finalizado',
'hora_fin' => now()
]);
}
private function mezclarOpciones(?array $opciones): array
{

@ -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,7 +15,10 @@ 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\Models\ProcesoAdmisionDetalle;
use App\Http\Controllers\Administracion\PostulanteController;
use App\Http\Controllers\Administracion\CalificacionController;
use App\Http\Controllers\Administracion\NoticiaController;
use App\Http\Controllers\WebController;
Route::get('/user', function (Request $request) {
return $request->user();
@ -63,6 +65,22 @@ Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
});
Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
// NOTICIAS
Route::get('/noticias', [NoticiaController::class, 'index']);
Route::get('/noticias/{noticia}', [NoticiaController::class, 'show']);
Route::post('/noticias', [NoticiaController::class, 'store']);
// usa SOLO UNA (PUT o PATCH). Aquí dejo PUT:
Route::put('/noticias/{noticia}', [NoticiaController::class, 'update']);
Route::delete('/noticias/{noticia}', [NoticiaController::class, 'destroy']);
});
Route::get('/noticias', [NoticiaController::class, 'index']);
Route::get('/noticias/{noticia:slug}', [NoticiaController::class, 'showPublic']);
Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
Route::get('/cursos', [CursoController::class, 'index']);
@ -74,6 +92,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 +111,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 () {
@ -157,6 +194,7 @@ Route::middleware(['auth:postulante'])->group(function () {
// Finalizar examen
Route::post('/examen/{examen}/finalizar', [ExamenController::class, 'finalizarExamen']);
Route::post('/examen/{examenId}/calificar', [ExamenController::class, 'calificarExamen']);
});
@ -186,3 +224,7 @@ Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
Route::delete('/{id}', [ProcesoAdmisionDetalleController::class, 'destroy'])->name('destroy');
});
});
Route::get('/procesos-admision', [WebController::class, 'GetProcesoAdmision']);

@ -2,10 +2,9 @@
<html lang="es">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/logotiny.png" />
<link rel="icon" type="image/svg+xml" href="" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Direccion de Admision - Universidad Nacional del Altiplano Puno. Procesos de admision, convocatorias, requisitos e inscripciones." />
<title>Direccion de Admision - UNA Puno</title>
<title>Admisión</title>
</head>
<body>
<div id="app"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

@ -9,7 +9,7 @@ const api = axios.create({
}
});
// Request interceptor
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token')
@ -25,7 +25,7 @@ api.interceptors.request.use(
}
)
// Response interceptor
api.interceptors.response.use(
(response) => {
return response
@ -33,21 +33,19 @@ api.interceptors.response.use(
async (error) => {
const originalRequest = error.config
// Si el error es 401 y no es un intento de re-autenticación
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true
// Limpiar autenticación
localStorage.removeItem('token')
localStorage.removeItem('user')
// Redirigir a login
router.push('/login')
router.push('account/auth/login')
return Promise.reject(error)
}
// Manejar otros errores
if (error.response?.status === 403) {
router.push('/unauthorized')
}

@ -13,7 +13,7 @@
<p class="footer-text">
Institución pública de educación superior comprometida con la
formación académica, científica y humanística de la región.
formación académica de la región.
</p>
</div>
@ -40,8 +40,8 @@
<h4>Contacto</h4>
<ul>
<li>Av. Floral N° 1153 Puno</li>
<li>📞 (051) 123-456</li>
<li> admision@unap.edu.pe</li>
<li>📞 (+51) 957 734 361</li>
<li> dgadmision@unap.edu.pe</li>
</ul>
</div>
</div>

@ -9,66 +9,19 @@
<ProcessSection :proceso="procesoPrincipal" />
<ConvocatoriasSection
:procesos="procesosPublicados"
@show-modal="showModal"
/>
<ConvocatoriasSection/>
<ProgramasSection :facultades="facultades" />
<!-- <ProgramasSection/> -->
<StatsSection />
<NoticiasSection :noticias="noticias" />
<NoticiasSection/>
<ModalidadesSection :modalidades="modalidades" />
<ContactSection />
</div>
<PreinscripcionModal
v-model:visible="preinscripcionModalVisible"
:facultades="facultades"
@submit="submitPreinscripcion"
/>
<!-- Modal de detalles de convocatoria -->
<a-modal
v-model:open="detalleModalVisible"
:title="detalleModal.titulo"
:footer="null"
width="700px"
centered
>
<div v-if="detalleModal.imagen_url" class="detalle-modal-imagen">
<a-image
:src="detalleModal.imagen_url"
:alt="detalleModal.titulo"
style="width: 100%; max-height: 350px; object-fit: cover; border-radius: 8px;"
/>
</div>
<div v-if="detalleModal.descripcion" class="detalle-modal-desc" style="margin-top: 16px;">
<p style="white-space: pre-line; color: #555; line-height: 1.7;">{{ detalleModal.descripcion }}</p>
</div>
<div v-if="detalleModal.listas && detalleModal.listas.length > 0" style="margin-top: 12px;">
<ul style="padding-left: 20px; color: #555;">
<li v-for="(item, idx) in detalleModal.listas" :key="idx">{{ item }}</li>
</ul>
</div>
<div v-if="detalleModal.imagen_url_2" class="detalle-modal-imagen" style="margin-top: 16px;">
<a-image
:src="detalleModal.imagen_url_2"
:alt="detalleModal.titulo"
style="width: 100%; max-height: 350px; object-fit: cover; border-radius: 8px;"
/>
</div>
<div v-if="!detalleModal.descripcion && !detalleModal.imagen_url && (!detalleModal.listas || detalleModal.listas.length === 0)">
<a-empty description="No hay información disponible para esta sección." />
</div>
</a-modal>
<FooterModerno />
</template>
@ -89,184 +42,9 @@ import StatsSection from './WebPageSections/StatsSection.vue'
import NoticiasSection from './WebPageSections/NoticiasSection.vue'
import ModalidadesSection from './WebPageSections/ModalidadesSection.vue'
import ContactSection from './WebPageSections/ContactSection.vue'
import PreinscripcionModal from './WebPageSections//modal/PreinscripcionModal.vue'
import {
MedicineBoxOutlined,
BuildOutlined,
CodeOutlined,
BookOutlined,
TrophyOutlined,
BankOutlined,
ExperimentOutlined,
UserOutlined,
} from "@ant-design/icons-vue"
const procesoStore = useProcesoAdmisionStore()
const procesosPublicados = computed(() => procesoStore.procesosPublicados)
const procesoPrincipal = computed(() => procesoStore.procesosPublicados[0] || null)
onMounted(() => {
procesoStore.fetchProcesosPublicados()
})
const preinscripcionModalVisible = ref(false)
const detalleModalVisible = ref(false)
const detalleModal = ref({
titulo: '',
descripcion: '',
imagen_url: null,
imagen_url_2: null,
listas: [],
})
const facultades = [
{
id: "1",
nombre: "Ciencias de la Salud",
carreras: [
{
id: 1,
nombre: "Medicina Humana",
grado: "Bachiller",
descripcion: "Formación médica integral con prácticas desde primer año",
vacantes: 50,
puntaje: "1800+",
icono: markRaw(MedicineBoxOutlined),
},
{
id: 2,
nombre: "Enfermería",
grado: "Bachiller",
descripcion: "Cuidado integral de la salud",
vacantes: 60,
puntaje: "1500+",
icono: markRaw(UserOutlined),
},
],
},
{
id: "2",
nombre: "Ingenierías",
carreras: [
{
id: 3,
nombre: "Ingeniería Civil",
grado: "Bachiller",
descripcion: "Diseño y construcción de infraestructura",
vacantes: 80,
puntaje: "1700+",
icono: markRaw(BuildOutlined),
},
{
id: 4,
nombre: "Ingeniería de Sistemas",
grado: "Bachiller",
descripcion: "Desarrollo de software e inteligencia artificial",
vacantes: 100,
puntaje: "1600+",
icono: markRaw(CodeOutlined),
},
],
},
{
id: "3",
nombre: "Derecho y Humanidades",
carreras: [
{
id: 5,
nombre: "Derecho",
grado: "Bachiller",
descripcion: "Formación jurídica integral",
vacantes: 120,
puntaje: "1550+",
icono: markRaw(BookOutlined),
},
{
id: 6,
nombre: "Psicología",
grado: "Bachiller",
descripcion: "Ciencias del comportamiento humano",
vacantes: 70,
puntaje: "1450+",
icono: markRaw(UserOutlined),
},
],
},
]
const modalidades = [
{
id: 1,
nombre: "Admisión Ordinaria",
descripcion: "Examen de conocimientos generales",
estado: "Abierto",
estadoColor: "success",
color: "#1890ff",
icono: markRaw(BookOutlined),
},
{
id: 2,
nombre: "Evaluación de Talentos",
descripcion: "Para deportistas y artistas destacados",
estado: "Próximamente",
estadoColor: "orange",
color: "#faad14",
icono: markRaw(TrophyOutlined),
},
{
id: 3,
nombre: "Traslado Externo",
descripcion: "Estudiantes de otras universidades",
estado: "Cerrado",
estadoColor: "red",
color: "#ff4d4f",
icono: markRaw(BankOutlined),
},
{
id: 4,
nombre: "Segunda Carrera",
descripcion: "Para profesionales graduados",
estado: "Abierto",
estadoColor: "success",
color: "#52c41a",
icono: markRaw(ExperimentOutlined),
},
]
const noticias = [
{
id: 1,
titulo: "Nuevo Laboratorio de Investigación",
descripcion: "Inauguramos el moderno laboratorio de ciencias con tecnología de punta.",
fecha: "15 Nov 2023",
categoria: "Infraestructura",
tagColor: "blue",
imagen:
"https://images.unsplash.com/photo-1532094349884-543bc11b234d?auto=format&fit=crop&w=600&q=80",
},
{
id: 2,
titulo: "Convenio Internacional",
descripcion: "Firmamos acuerdo con universidad europea para intercambio estudiantil.",
fecha: "10 Nov 2023",
categoria: "Internacional",
tagColor: "green",
imagen:
"https://images.unsplash.com/photo-1523050854058-8df90110c9f1?auto=format&fit=crop&w=600&q=80",
},
{
id: 3,
titulo: "Resultados Publicados",
descripcion: "Consulta los resultados del examen de admisión extraordinario.",
fecha: "5 Nov 2023",
categoria: "Resultados",
tagColor: "red",
imagen:
"https://images.unsplash.com/photo-1562774053-701939374585?auto=format&fit=crop&w=600&q=80",
},
]
const scrollToConvocatoria = () => {
const el = document.getElementById("convocatorias")
@ -277,48 +55,6 @@ const openVirtualTour = () => {
window.open("https://example.com", "_blank", "noopener,noreferrer")
}
const openPreinscripcion = () => {
preinscripcionModalVisible.value = true
}
const tipoLabels = {
requisitos: 'Requisitos',
pagos: 'Pagos',
vacantes: 'Vacantes',
cronograma: 'Cronograma',
}
const showModal = ({ procesoId, tipo }) => {
const proceso = procesosPublicados.value.find(p => p.id === procesoId)
if (!proceso) return
const detalle = proceso.detalles?.find(d => d.tipo === tipo)
if (detalle) {
detalleModal.value = {
titulo: detalle.titulo_detalle || tipoLabels[tipo] || tipo,
descripcion: detalle.descripcion || '',
imagen_url: detalle.imagen_url || null,
imagen_url_2: detalle.imagen_url_2 || null,
listas: detalle.listas || [],
}
} else {
detalleModal.value = {
titulo: `${tipoLabels[tipo] || tipo} - ${proceso.titulo}`,
descripcion: '',
imagen_url: null,
imagen_url_2: null,
listas: [],
}
}
detalleModalVisible.value = true
}
const submitPreinscripcion = () => {
message.success("Preinscripción iniciada exitosamente")
preinscripcionModalVisible.value = false
}
</script>
<style scoped>

@ -1,230 +1,167 @@
<!-- components/contact/ContactSection.vue -->
<template>
<section class="contact-section">
<section class="process-section" aria-labelledby="faq-title">
<div class="section-container">
<!-- Preguntas Frecuentes -->
<div class="faq-block">
<div class="section-header">
<h2 class="section-title">Preguntas Frecuentes</h2>
<p class="section-subtitle">Resuelve tus dudas sobre el proceso de admision</p>
<h2 id="faq-title" class="section-title">
Preguntas Frecuentes
</h2>
<p class="section-subtitle">
Resolvemos las dudas más comunes sobre el proceso de admisión y el uso de nuestra plataforma.
</p>
</div>
<a-collapse
v-model:activeKey="activeFaqKeys"
:bordered="false"
expand-icon-position="end"
class="faq-collapse"
>
<a-collapse-panel
v-for="(faq, index) in faqs"
:key="String(index)"
class="faq-panel"
>
<template #header>
<div class="faq-header">
<span class="faq-number">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="faq-question">{{ faq.pregunta }}</span>
</div>
</template>
<div class="faq-answer" v-html="faq.respuesta"></div>
<div class="process-card">
<a-collapse accordion class="modern-collapse">
<a-collapse-panel key="1" header="01. ¿Puedo postular con DNI caducado?">
No, su DNI debe estar vigente al momento de la inscripción.
En su defecto, puede presentar la Hoja C4 o el ticket de trámite acompañado de una copia del DNI.
</a-collapse-panel>
<a-collapse-panel key="2" header="02. ¿Qué requisitos debo presentar si postulo como persona con discapacidad?">
Certificado de Discapacidad emitido por entidades de salud acreditadas en el RENIPRESS.
Este debe ser adjuntado junto a los demás requisitos.
</a-collapse-panel>
<a-collapse-panel key="3" header="03. ¿Qué hago si ingreso mal mis datos en la preinscripción?">
No es posible modificarlos en línea una vez guardados.
Debe acercarse al local de inscripción y solicitar ayuda en la oficina de soporte técnico.
</a-collapse-panel>
<a-collapse-panel key="4" header="04. ¿Cuáles son los códigos para realizar los pagos?">
<p><b>Código 26:</b> Derechos de Admisión.</p>
<p><b>Código 27:</b> Rezagados al proceso de inscripción.</p>
<p><b>Código 28:</b> Carpeta de postulante.</p>
</a-collapse-panel>
<a-collapse-panel key="5" header="05. ¿Cómo debo realizar la preinscripción e inscripción?">
Ingrese a admision.unap.edu.pe para la preinscripción.
<br /><br />
La inscripción presencial se realiza según el último dígito del DNI, según el cronograma oficial.
</a-collapse-panel>
<a-collapse-panel key="6" header="06. ¿Qué requisitos debo presentar si postulo a Medicina Humana o Educación Física?">
Constancia de evaluación médica emitida por un establecimiento de salud primario,
además de los demás requisitos exigidos.
</a-collapse-panel>
<a-collapse-panel key="7" header="07. ¿Puedo cambiar de programa de estudio luego de inscribirme?">
. Tras obtener su constancia de inscripción, debe realizar un pago de S/.100 (Código 27)
en caja de la Universidad o Banco de la Nación, incluyendo comisión.
</a-collapse-panel>
<a-collapse-panel key="8" header="08. ¿Qué debo traer para inscribirme presencialmente?">
<ul>
<li>Comprobantes de pago o vouchers</li>
<li>DNI original y copia</li>
<li>Solicitud de Preinscripción impresa</li>
<li>Certificado de estudios original y copia</li>
</ul>
</a-collapse-panel>
<a-collapse-panel key="9" header="09. ¿Los postulantes con discapacidad hacen cola?">
No. Deben presentar su Certificado de Discapacidad y se les brindará atención preferencial.
</a-collapse-panel>
</a-collapse>
</div>
</div>
</section>
</template>
<script setup>
import { ref } from 'vue'
const activeFaqKeys = ref([])
const faqs = [
{
pregunta: '¿Puedo postular con DNI caducado?',
respuesta: 'No, su DNI debe estar vigente al momento de la inscripcion. En su defecto, puede presentar la Hoja C4 o el ticket de tramite acompañado de una copia del DNI.',
},
{
pregunta: '¿Que requisitos debo presentar si postulo como persona con discapacidad?',
respuesta: 'Certificado de Discapacidad emitido por entidades de salud acreditadas en el RENIPRESS. Este debe ser adjuntado junto a los demas requisitos.',
},
{
pregunta: '¿Que hago si ingreso mal mis datos en la preinscripcion?',
respuesta: 'No es posible modificarlos en linea una vez guardados. Debe acercarse al local de inscripcion y solicitar ayuda en la oficina de soporte tecnico.',
},
{
pregunta: '¿Cuales son los codigos para realizar los pagos?',
respuesta: '<p><strong>Codigo 26:</strong> Derechos de Admision.</p><p><strong>Codigo 27:</strong> Rezagados al proceso de inscripcion.</p><p><strong>Codigo 28:</strong> Carpeta de postulante.</p>',
},
{
pregunta: '¿Como debo realizar la preinscripcion e inscripcion?',
respuesta: 'Ingrese a <a href="https://admision.unap.edu.pe" target="_blank" rel="noopener noreferrer" class="faq-link">admision.unap.edu.pe</a> para la preinscripcion. La inscripcion presencial se realiza segun el ultimo digito del DNI, segun el cronograma oficial.',
},
{
pregunta: '¿Que requisitos debo presentar si postulo a Medicina Humana o Educacion Fisica?',
respuesta: 'Constancia de evaluacion medica emitida por un establecimiento de salud primario, ademas de los demas requisitos exigidos.',
},
{
pregunta: '¿Puedo cambiar de programa de estudio luego de inscribirme?',
respuesta: 'Si. Tras obtener su constancia de inscripcion, debe realizar un pago de S/.100 (Codigo 27) en caja de la Universidad o Banco de la Nacion, incluyendo comision.',
},
{
pregunta: '¿Que debo traer para inscribirme presencialmente?',
respuesta: '<ul><li>Comprobantes de pago o vouchers</li><li>DNI original y copia</li><li>Solicitud de Preinscripcion impresa (descargada de la web)</li><li>Certificado de estudios original y copia</li></ul>',
},
{
pregunta: '¿Los postulantes con discapacidad hacen cola?',
respuesta: 'No. Deben presentar su Certificado de Discapacidad y se les brindara atencion preferencial.',
},
]
</script>
<style scoped>
.contact-section {
padding: 60px 0 40px;
font-family: "Lora", serif;
.process-section {
padding: 30px 0;
background: #ffffff;
font-family: "Times New Roman", Times, serif;
}
.section-container {
max-width: 1200px;
max-width: 1100px;
margin: 0 auto;
padding: 0 24px;
}
/* ── FAQ ── */
.faq-block {
margin-bottom: 0;
padding: 0 20px;
}
.section-header {
text-align: center;
margin-bottom: 48px;
margin-bottom: 18px;
}
.section-title {
font-size: 2.5rem;
font-size: 2.1rem;
font-weight: 700;
color: #0d1b52;
margin: 0 0 12px;
color: #2c3e50;
margin: 0 0 6px 0;
}
.section-subtitle {
font-size: 1.125rem;
color: #666;
max-width: 540px;
margin: 0 auto;
}
.faq-collapse {
background: transparent;
font-size: 1rem;
color: #777;
margin: 0;
}
.faq-panel {
.process-card {
border: 1px solid #e5e7eb;
border-radius: 14px;
padding: 20px 18px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.04);
background: #fff;
border: 1px solid #e8ecf4 !important;
border-radius: 14px !important;
margin-bottom: 14px;
overflow: hidden;
transition: box-shadow 0.25s ease;
}
.faq-panel:hover {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.07);
}
.faq-panel :deep(.ant-collapse-header) {
padding: 20px 24px !important;
align-items: center !important;
}
.faq-panel :deep(.ant-collapse-content-box) {
padding: 0 24px 22px 24px !important;
}
.faq-header {
display: flex;
align-items: center;
gap: 14px;
}
.faq-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, #1a237e 0%, #283593 100%);
color: #fff;
font-size: 0.8rem;
font-weight: 700;
flex-shrink: 0;
}
.faq-question {
font-weight: 600;
color: #1a237e;
font-size: 1rem;
line-height: 1.4;
}
.faq-answer {
color: #555;
font-size: 0.975rem;
line-height: 1.75;
padding-left: 50px;
.illustration-box {
text-align: center;
margin-bottom: 20px;
}
.faq-answer :deep(ul) {
padding-left: 20px;
margin: 0;
.illustration-img {
width: 160px;
margin-bottom: 8px;
}
.faq-answer :deep(ul li) {
margin-bottom: 4px;
.illustration-text {
font-size: 0.9rem;
color: #6b7280;
}
.faq-answer :deep(p) {
margin: 0 0 4px;
.modern-collapse {
background: transparent;
}
.faq-answer :deep(.faq-link) {
color: #1890ff;
text-decoration: underline;
.modern-collapse :deep(.ant-collapse-item) {
border-radius: 10px !important;
margin-bottom: 10px;
background: #f8fafc;
border: 1px solid #e5e7eb;
}
.faq-answer :deep(.faq-link:hover) {
color: #0d1b52;
.modern-collapse :deep(.ant-collapse-header) {
font-weight: 700;
color: #1e3a8a;
}
@media (max-width: 992px) {
.section-title {
font-size: 2rem;
}
.faq-answer {
padding-left: 0;
}
.modern-collapse :deep(.ant-collapse-content) {
background: #ffffff;
border-top: 1px solid #eef2f7;
}
@media (max-width: 768px) {
.contact-section {
padding: 40px 0 30px;
}
.faq-panel :deep(.ant-collapse-header) {
padding: 16px 18px !important;
.section-title {
font-size: 1.6rem;
}
.faq-number {
width: 32px;
height: 32px;
font-size: 0.75rem;
.process-card {
padding: 14px 12px;
}
.faq-question {
font-size: 0.925rem;
.illustration-img {
width: 120px;
}
}
</style>

@ -1,4 +1,4 @@
<!-- components/convocatorias/ConvocatoriasSection.vue -->
<!-- src/components/convocatorias/ConvocatoriasSection.vue -->
<template>
<section id="convocatorias" class="convocatorias-modern">
<div class="section-container">
@ -12,57 +12,88 @@
</p>
</div>
<!-- Sin procesos publicados -->
<div v-if="procesos.length === 0" class="empty-state">
<p>No hay convocatorias vigentes en este momento.</p>
</div>
<a-skeleton v-if="store.loading" active :paragraph="{ rows: 8 }" />
<div v-else class="convocatorias-grid">
<!-- Card principal: primer proceso -->
<a-card class="main-convocatoria-card">
<a-card v-if="store.procesoPrincipal" class="main-convocatoria-card">
<div class="card-badge">Principal</div>
<div class="main-card-grid">
<div class="main-card-text">
<div class="convocatoria-header">
<div>
<h3>{{ principal.titulo }}</h3>
<h3>{{ store.procesoPrincipal.titulo }}</h3>
<a-divider class="custom-divider" />
<p class="convocatoria-date">
PreInscripciones:
{{ formatFecha(store.procesoPrincipal.fecha_inicio_preinscripcion) }}
-
{{ formatFecha(store.procesoPrincipal.fecha_fin_inscripcion) }}
</p>
<p class="convocatoria-date">
Inscripciones:
{{ formatFecha(store.procesoPrincipal.fecha_inicio_inscripcion) }}
-
{{ formatFecha(store.procesoPrincipal.fecha_fin_inscripcion) }}
</p>
<p class="convocatoria-date">
{{ formatFechasInscripcion(principal) }}
Examen:
{{ formatFecha(store.procesoPrincipal.fecha_examen1) }}
</p>
</div>
<a-tag :color="getEstadoColor(principal.estado)" class="status-tag">
{{ getEstadoLabel(principal.estado) }}
<a-tag color="success" class="status-tag">
{{ store.procesoPrincipal.estado }}
</a-tag>
</div>
<p class="convocatoria-desc">
{{ principal.descripcion || principal.subtitulo || 'Proceso de admisión' }}
{{ store.procesoPrincipal.descripcion }}
</p>
<a-divider class="custom-divider" />
<div class="quick-actions">
<!-- ACCIONES RAPIDAS -->
<div
v-if="store.procesoPrincipal?.detalles?.length"
class="quick-actions"
>
<h4 class="subheading">Acciones Rápidas</h4>
<div class="action-buttons-grid">
<a-button class="action-btn" @click="$emit('show-modal', { procesoId: principal.id, tipo: 'requisitos' })">
<a-button
v-if="tieneTipo('requisitos')"
class="action-btn"
@click="abrirPorTipo('requisitos')"
>
<template #icon><FileTextOutlined /></template>
Requisitos
</a-button>
<a-button class="action-btn" @click="$emit('show-modal', { procesoId: principal.id, tipo: 'pagos' })">
<a-button
v-if="tieneTipo('pagos')"
class="action-btn"
@click="abrirPorTipo('pagos')"
>
<template #icon><DollarOutlined /></template>
Pagos
</a-button>
<a-button class="action-btn" @click="$emit('show-modal', { procesoId: principal.id, tipo: 'vacantes' })">
<a-button
v-if="tieneTipo('vacantes')"
class="action-btn"
@click="abrirPorTipo('vacantes')"
>
<template #icon><TeamOutlined /></template>
Vacantes
</a-button>
<a-button class="action-btn" @click="$emit('show-modal', { procesoId: principal.id, tipo: 'cronograma' })">
<a-button
v-if="tieneTipo('cronograma')"
class="action-btn"
@click="abrirPorTipo('cronograma')"
>
<template #icon><CalendarOutlined /></template>
Cronograma
</a-button>
@ -74,14 +105,16 @@
<div class="preinscripcion-section">
<div class="preinscripcion-info">
<h4 class="subheading">Preinscripción en Línea</h4>
<p>Completa tu preinscripción de manera digital y segura</p>
<p>Completa tu preinscripción de manera virtual y segura</p>
</div>
<div v-if="store.procesoPrincipal?.link_preinscripcion" class="preinscripcion-btn-wrap">
<a-button
type="primary"
size="large"
class="preinscripcion-btn"
@click="abrirPreinscripcion(principal)"
:href="store.procesoPrincipal.link_preinscripcion"
target="_blank"
>
<template #icon><FormOutlined /></template>
Iniciar Preinscripción
@ -89,6 +122,9 @@
</div>
</div>
</div>
<div class="main-card-media">
<a-image
:src="principal.imagen_url || '/images/extra.jpg'"
@ -100,45 +136,97 @@
</div>
</a-card>
<!-- Cards secundarias: resto de procesos -->
<div v-if="secundarios.length > 0" class="secondary-list">
<a-card
v-for="proceso in secundarios"
:key="proceso.id"
class="secondary-convocatoria-card"
>
<div class="secondary-list">
<a-card class="secondary-convocatoria-card">
<div class="convocatoria-header">
<div>
<h4 class="secondary-title">{{ proceso.titulo }}</h4>
<p class="convocatoria-date">{{ formatFechasInscripcion(proceso) }}</p>
<h4 class="secondary-title">Extraordinario</h4>
<p class="convocatoria-date">30 de enero</p>
</div>
<a-tag class="status-tag" :color="getEstadoColor(proceso.estado)">
{{ getEstadoLabel(proceso.estado) }}
</a-tag>
<a-tag class="status-tag" color="orange">Finalizado</a-tag>
</div>
<p class="convocatoria-desc">
{{ proceso.descripcion || proceso.subtitulo || 'Proceso de admisión' }}
Modalidad extraordinaria para perfiles específicos
</p>
<div class="card-footer">
<a-button type="link" size="small" @click="$emit('show-modal', { procesoId: proceso.id, tipo: 'requisitos' })">
<!-- <div class="card-footer">
<a-button
type="link"
size="small"
@click="emit('show-modal', 'extraordinario')"
>
Ver detalles
</a-button>
<a-button type="primary" ghost size="small" @click="abrirPreinscripcion(proceso)">
Consultar
</a-button>
<a-button type="primary" ghost size="small">Consultar</a-button>
</div> -->
</a-card>
<a-card class="secondary-convocatoria-card">
<div class="convocatoria-header">
<div>
<h4 class="secondary-title">CEPREUNA</h4>
<p class="convocatoria-date">10 y 11 de enero</p>
</div>
<a-tag class="status-tag" color="orange">Finalizado</a-tag>
</div>
<p class="convocatoria-desc">Postulantes del CEPREUNA</p>
<!-- <div class="card-footer">
<a-button type="link" size="small" @click="emit('show-modal', 'cepreuna')">
Ver detalles
</a-button>
<a-button type="primary" ghost size="small">Consultar</a-button>
</div> -->
</a-card>
</div>
</div>
</div>
</section>
<a-modal
v-model:open="modalVisible"
:title="tituloModal"
width="400px"
centered
@ok="modalVisible = false"
@cancel="modalVisible = false"
>
<div
v-for="detalle in detallesSeleccionados"
:key="detalle.id"
style="margin-bottom: 25px"
>
<p v-if="detalle.descripcion">
{{ detalle.descripcion }}
</p>
<ul v-if="detalle.listas?.length">
<li v-for="(item, i) in detalle.listas" :key="i">
{{ item }}
</li>
</ul>
<a-image
v-if="detalle.imagen_url"
:src="detalle.imagen_url"
:preview="true"
class="detalle-img"
/>
</div>
</a-modal>
</template>
<script setup>
import { computed } from 'vue'
import { onMounted, ref } from "vue"
import { useWebAdmisionStore } from "../../store/web"
import {
FileTextOutlined,
DollarOutlined,
@ -147,63 +235,48 @@ import {
FormOutlined,
} from "@ant-design/icons-vue"
const props = defineProps({
procesos: {
type: Array,
default: () => [],
},
})
const store = useWebAdmisionStore()
defineEmits(["show-modal"])
const principal = computed(() => props.procesos[0] || {})
const secundarios = computed(() => props.procesos.slice(1))
const estadoMap = {
publicado: { label: 'Abierto', color: 'success' },
en_proceso: { label: 'En Proceso', color: 'processing' },
nuevo: { label: 'PRÓXIMAMENTE', color: 'orange' },
finalizado: { label: 'FINALIZADO', color: 'default' },
cancelado: { label: 'CANCELADO', color: 'red' },
}
const modalVisible = ref(false)
const detallesSeleccionados = ref([])
const tituloModal = ref("")
onMounted(() => {
store.cargarProcesos()
})
function getEstadoLabel(estado) {
return estadoMap[estado]?.label || estado || 'Abierto'
const formatFecha = (fecha) => {
if (!fecha) return ""
return new Date(fecha).toLocaleDateString("es-PE", {
day: "2-digit",
month: "short",
year: "numeric",
})
}
function getEstadoColor(estado) {
return estadoMap[estado]?.color || 'success'
const tieneTipo = (tipo) => {
return store.procesoPrincipal?.detalles?.some((d) => d.tipo === tipo)
}
function formatFechasInscripcion(proceso) {
if (!proceso.fecha_inicio_inscripcion && !proceso.fecha_fin_inscripcion) {
return ''
}
const opts = { day: 'numeric', month: 'short' }
const inicio = proceso.fecha_inicio_inscripcion
? new Date(proceso.fecha_inicio_inscripcion).toLocaleDateString('es-PE', opts)
: ''
const fin = proceso.fecha_fin_inscripcion
? new Date(proceso.fecha_fin_inscripcion).toLocaleDateString('es-PE', opts)
: ''
if (inicio && fin) return `Inscripciones: ${inicio} - ${fin}`
if (inicio) return `Inicio: ${inicio}`
return `Hasta: ${fin}`
}
function abrirPreinscripcion(proceso) {
if (proceso.link_preinscripcion) {
window.open(proceso.link_preinscripcion, '_blank', 'noopener,noreferrer')
}
const abrirPorTipo = (tipo) => {
detallesSeleccionados.value =
store.procesoPrincipal?.detalles?.filter((d) => d.tipo === tipo) ?? []
tituloModal.value = tipo.charAt(0).toUpperCase() + tipo.slice(1)
modalVisible.value = true
}
</script>
<style scoped>
.convocatorias-modern {
position: relative;
padding: 40px 0;
font-family: "Lora", serif;
font-family: "Times New Roman", Times, serif;
background: #fbfcff;
overflow: hidden;
}
@ -215,8 +288,7 @@ function abrirPreinscripcion(proceso) {
pointer-events: none;
z-index: 0;
background-image:
repeating-linear-gradient(
background-image: repeating-linear-gradient(
to right,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
@ -233,7 +305,13 @@ function abrirPreinscripcion(proceso) {
opacity: 0.55;
}
.detalle-img :deep(img) {
width: 100%;
max-width: 720px;
max-height: 420px;
object-fit: contain;
border-radius: 12px;
}
.section-container {
position: relative;
z-index: 1;
@ -396,10 +474,17 @@ function abrirPreinscripcion(proceso) {
}
.preinscripcion-section {
display: grid;
gap: 10px;
padding: 14px 12px;
border-radius: 12px;
background: rgba(24, 144, 255, 0.05);
border: 1px solid rgba(24, 144, 255, 0.18);
}
.preinscripcion-btn-wrap {
display: flex;
justify-content: space-between;
align-items: center;
gap: 18px;
justify-content: center;
margin-top: 6px;
}
.preinscripcion-info p {
@ -407,14 +492,33 @@ function abrirPreinscripcion(proceso) {
color: #666;
}
.preinscripcion-btn {
background: linear-gradient(135deg, #1a237e 0%, #283593 100%);
border: none;
height: 52px;
font-weight: 700;
.preinscripcion-btn-wrap {
display: flex;
justify-content: center;
margin-top: 6px;
}
.preinscripcion-btn-light {
height: 46px;
padding: 0 18px;
border-radius: 12px;
font-weight: 700;
background: #ffffff;
border: 1px solid rgba(24, 144, 255, 0.35);
color: #1890ff;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.06);
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
}
.preinscripcion-btn-light:hover {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.10);
border-color: rgba(24, 144, 255, 0.55);
}
.secondary-list {
display: grid;
grid-template-columns: repeat(2, 1fr);

@ -1,12 +1,11 @@
<!-- components/hero/HeroSection.vue -->
<template>
<section class="hero" aria-label="Sección principal de admisión">
<div class="hero-bg" aria-hidden="true">
<div class="hero-grid"></div>
<div class="hero-shape hero-shape-1"></div>
<div class="hero-shape hero-shape-2"></div>
</div>
<section
class="hero"
:style="{ backgroundImage: `url(${heroImg})` }"
aria-label="Sección principal de admisión"
>
<div class="hero-overlay"></div>
<div class="hero-container">
@ -18,14 +17,18 @@
Inscripciones abiertas
</span>
</div>
<div class="glass-cardtitle">
<h1 class="hero-title">
Admisión <span class="hero-year">2026</span>
</h1>
</div>
<p class="hero-subtitle">
Forma parte de una comunidad académica de excelencia.
<span class="hero-subtitle-muted">Postula, infórmate y conoce el proceso.</span>
<span class="hero-subtitle-muted">
Postula, infórmate y conoce el proceso.
</span>
</p>
<div class="hero-actions">
@ -39,78 +42,48 @@
Postular ahora
</a-button>
<a-button size="large" class="secondary-button" @click="$emit('virtual-tour')">
<template #icon><PlayCircleOutlined /></template>
Tour virtual
</a-button>
</div>
<div class="hero-metrics">
<div class="metric">
<span class="metric-value">44</span>
<span class="metric-label">Programas</span>
</div>
<div class="metric">
<span class="metric-value">3</span>
<span class="metric-label">Áreas</span>
</div>
<div class="metric">
<span class="metric-value">+10</span>
<span class="metric-label">Sedes</span>
</div>
</div>
</div>
<!-- Visual -->
<div class="hero-visual">
<div class="visual-stack">
<div class="floating-card">
<!-- CARD -->
<!-- <div class="hero-visual">
<div class="glass-card">
<div class="card-header">
<CalendarOutlined />
<span>Próximo evento</span>
</div>
<h3 class="card-title">Charla informativa</h3>
<h3>Charla informativa</h3>
<div class="card-info">
<span class="info-item">
<p class="card-date">
<ClockCircleOutlined />
25 Nov 4:00 PM
</span>
<span class="info-item">
<VideoCameraOutlined />
Virtual
</span>
</div>
25 Nov 4:00 PM Virtual
</p>
<a-button type="primary" ghost class="card-cta" size="middle">
Registrarse
<a-button
size="large"
class="secondary-button"
@click="$emit('virtual-tour')"
>
<template #icon><PlayCircleOutlined /></template>
Tour virtual
</a-button>
</div>
</div> -->
<div class="mini-card" aria-hidden="true">
<span class="mini-dot"></span>
<div class="mini-text">
<div class="mini-title">Guía del postulante</div>
<div class="mini-sub">Requisitos y fechas</div>
</div>
<ArrowRightOutlined class="mini-icon" />
</div>
</div>
</div>
</div>
</section>
</template>
<script setup>
const heroImg = "/PORTADA.jpg.jpeg";
import {
RightCircleOutlined,
PlayCircleOutlined,
CalendarOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
VideoCameraOutlined,
ArrowRightOutlined,
ClockCircleOutlined
} from "@ant-design/icons-vue";
defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
@ -124,340 +97,153 @@ defineEmits(["scroll-to-convocatoria", "virtual-tour"]);
}
.hero {
--inst-primary: #1a237e; /* AZUL institucional (ejemplo) */
--inst-secondary: #0f172a; /* oscuro de apoyo */
--inst-accent: #c8a100; /* dorado institucional (ejemplo) */
position: relative;
padding: 110px 0;
color: white;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
overflow: hidden;
color: #ffffff;
padding: 88px 0;
background: var(--inst-primary); /* SIN DEGRADADOS */
--text: rgba(245, 247, 255, 0.92);
--muted: rgba(229, 235, 255, 0.72);
}
/* Fondo decorativo sutil (sin degradados) */
.hero-bg {
position: absolute;
inset: 0;
pointer-events: none;
}
/* Patrón de líneas muy suave */
.hero-grid {
position: absolute;
inset: 0;
opacity: 0.10;
background-image: linear-gradient(rgba(255, 255, 255, 0.14) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.14) 1px, transparent 1px);
background-size: 52px 52px;
}
.hero-shape {
position: absolute;
border-radius: 999px;
opacity: 0.16;
background: #ffffff;
}
.hero-shape-1 {
width: 460px;
height: 460px;
left: -220px;
top: -240px;
}
.hero-shape-2 {
width: 560px;
height: 560px;
right: -260px;
bottom: -320px;
}
.hero-container {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
margin: auto;
padding: 0 24px;
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 72px;
grid-template-columns: 1.2fr 0.8fr;
align-items: center;
gap: 60px;
}
/* BADGES */
.hero-badges {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
margin-bottom: 22px;
}
.hero-tag {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
color: #fff;
background: rgba(255, 255, 255, 0.12);
font-weight: 700;
color: rgb(255, 255, 255);
font-weight: bold;
}
.hero-pill {
display: inline-flex;
display: flex;
align-items: center;
gap: 8px;
gap: 6px;
background: rgba(255, 255, 255, 0.12);
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(0, 0, 0, 0.10);
font-size: 0.95rem;
font-weight: 700;
font-weight: bold;
}
.hero-title {
font-size: 3.4rem;
font-weight: 900;
line-height: 1.08;
margin: 0 0 18px;
letter-spacing: -0.01em;
font-size: 3.5rem;
font-weight: bold;
margin: 0 0 20px;
color: rgba(245, 247, 255, 0.96);
}
.hero-year {
color: var(--inst-accent);
position: relative;
}
font-size: 2.8rem;
font-weight: 900;
line-height: 1;
.hero-year::after {
content: "";
position: absolute;
left: 0;
bottom: -10px;
width: 100%;
height: 4px;
background: var(--inst-accent);
border-radius: 999px;
opacity: 0.9;
background: linear-gradient(45deg, #ffd700, #ff6b6b);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-subtitle {
font-size: 1.18rem;
opacity: 0.95;
margin: 0 0 32px;
line-height: 1.65;
max-width: 560px;
font-weight: 600;
}
.hero-subtitle-muted {
display: inline-block;
margin-left: 6px;
opacity: 0.9;
font-weight: 600;
font-size: 1.2rem;
max-width: 550px;
margin-bottom: 30px;
line-height: 1.6;
}
/* BOTONES */
.hero-actions {
display: flex;
gap: 14px;
gap: 15px;
flex-wrap: wrap;
}
.cta-button {
border: 1px solid rgba(255, 255, 255, 0.22);
font-weight: 800;
border-radius: 12px;
background: var(--inst-accent);
color: var(--inst-secondary);
}
.cta-button:hover {
filter: brightness(1.02);
transform: translateY(-1px);
font-weight: bold;
border-radius: 8px;
}
.secondary-button {
background: transparent;
border: 2px solid rgba(255, 255, 255, 0.28);
color: #fff;
border-radius: 12px;
font-weight: 800;
border: 2px solid rgba(255, 255, 255, 0.6);
color: white;
border-radius: 8px;
font-weight: bold;
}
.secondary-button:hover {
background: rgba(255, 255, 255, 0.08);
}
.hero-metrics {
margin-top: 28px;
display: flex;
gap: 14px;
flex-wrap: wrap;
}
.metric {
min-width: 120px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.10);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.metric-value {
display: block;
font-size: 1.25rem;
font-weight: 900;
line-height: 1.1;
color: #fff;
}
.metric-label {
display: block;
opacity: 0.9;
font-size: 0.95rem;
margin-top: 2px;
font-weight: 700;
}
/* ====== VISUAL ====== */
/* CARD */
.hero-visual {
display: flex;
justify-content: flex-end;
}
.visual-stack {
width: min(420px, 100%);
display: grid;
gap: 14px;
}
.floating-card {
background: #ffffff;
color: #111827;
padding: 22px;
border-radius: 18px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18);
border: 1px solid rgba(17, 24, 39, 0.08);
transition: transform 0.25s ease, box-shadow 0.25s ease;
.glass-card {
background: rgba(106, 136, 219, 0.55);
padding: 28px;
border-radius: 16px;
width: 100%;
max-width: 360px;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.3);
}
.floating-card:hover {
transform: translateY(-4px);
box-shadow: 0 28px 70px rgba(0, 0, 0, 0.22);
.glass-cardtitle {
background: rgba(190, 200, 228, 0.55);
padding: 12px 24px;
border-radius: 16px;
width: fit-content;
max-width: 100%;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
color: #6b7280;
margin-bottom: 14px;
font-weight: 800;
}
.card-title {
margin: 0 0 12px;
color: #111827;
font-weight: 900;
}
.card-info {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
margin-bottom: 14px;
color: #374151;
font-weight: 700;
}
.info-item {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.95rem;
font-weight: 800;
font-weight: bold;
}
.card-cta {
border-radius: 12px;
font-weight: 900;
border-color: var(--inst-primary);
color: var(--inst-primary);
}
.mini-card {
.card-date {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.10);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.mini-dot {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--inst-accent);
box-shadow: 0 0 0 6px rgba(200, 161, 0, 0.18);
}
.mini-title {
font-weight: 900;
line-height: 1.1;
}
.mini-sub {
opacity: 0.9;
font-size: 0.95rem;
margin-top: 2px;
font-weight: 700;
}
.mini-icon {
opacity: 0.9;
gap: 6px;
margin-bottom: 20px;
}
/* ====== Responsive ====== */
/* RESPONSIVE */
@media (max-width: 992px) {
.hero {
padding: 72px 0;
}
.hero-container {
grid-template-columns: 1fr;
text-align: center;
gap: 40px;
}
.hero-subtitle {
margin-left: auto;
margin-right: auto;
}
.hero-actions {
justify-content: center;
}
.hero-visual {
justify-content: center;
margin-top: 40px;
}
.hero-title {
font-size: 2.7rem;
font-size: 2.4rem;
}
.hero-metrics {
.hero-actions {
justify-content: center;
}
}
@media (max-width: 768px) {
.hero-title {
font-size: 2.15rem;
}
.metric {
min-width: 110px;
}
}
</style>

@ -1,7 +1,8 @@
<!-- src/components/web/NewsSection.vue -->
<template>
<section class="news-section">
<div class="container">
<!-- Header -->
<!-- Header centrado -->
<div class="header">
<div class="header-left">
<a-typography-title :level="2" class="title">
@ -12,35 +13,38 @@
Entérate de los anuncios, resultados y novedades institucionales
</a-typography-paragraph>
</div>
</div>
<a-divider class="divider" />
<!-- Grid -->
<div v-if="mappedNoticias.length">
<a-row :gutter="[24, 24]">
<a-col
v-for="noticia in noticias"
v-for="noticia in mappedNoticias"
:key="noticia.id"
:xs="24"
:sm="12"
:lg="8"
>
<a-badge-ribbon
:text="noticia.categoria"
:color="noticia.tagColor || 'blue'"
:text="noticia.categoria || 'Noticia'"
:color="noticia.tagColor"
>
<a-card
hoverable
class="card"
:bodyStyle="{ padding: '16px' }"
@click="openModal(noticia)"
>
<a-card hoverable class="card" :bodyStyle="{ padding: '16px' }">
<!-- Cover -->
<template #cover>
<template v-if="noticia.imagen" #cover>
<div
class="cover"
:style="{ backgroundImage: `url(${noticia.imagen})` }"
>
<div class="cover-overlay" />
<div class="date-pill">
<CalendarOutlined />
<span>{{ noticia.fecha }}</span>
@ -49,6 +53,11 @@
</template>
<a-space direction="vertical" size="small" class="content">
<div v-if="!noticia.imagen" class="date-inline">
<CalendarOutlined />
<span>{{ noticia.fecha }}</span>
</div>
<a-typography-title
:level="4"
class="card-title"
@ -62,16 +71,20 @@
:ellipsis="{ rows: 3, tooltip: noticia.descripcion }"
/>
<div class="actions">
<a-button type="link" class="read-more">
<a-button
type="link"
class="read-more"
@click.stop="openModal(noticia)"
>
Leer más
<ArrowRightOutlined />
</a-button>
<!-- Tag secundario opcional (si quieres mostrar algo extra)
<a-tag color="default" class="tag-soft">Institucional</a-tag>
-->
<a-tag v-if="noticia.destacado" color="gold" class="tag-soft">
Destacado
</a-tag>
</div>
</a-space>
</a-card>
@ -79,22 +92,180 @@
</a-col>
</a-row>
</div>
<a-modal
v-model:open="modalOpen"
:title="modalTitle"
:footer="null"
:width="860"
destroyOnClose
@afterClose="onAfterClose"
>
<div v-if="noticiasStore.loadingOne" style="padding: 8px 0">
<a-skeleton active :paragraph="{ rows: 6 }" />
</div>
<a-alert
v-else-if="noticiasStore.error"
type="error"
show-icon
:message="noticiasStore.error"
/>
<div v-else>
<div v-if="modalImage" class="modal-cover">
<img :src="modalImage" alt="Imagen de noticia" />
</div>
<div class="modal-meta">
<a-tag :color="modalTagColor">{{ modalCategoria }}</a-tag>
<span class="modal-date">
<CalendarOutlined />
{{ modalFecha }}
</span>
</div>
<a-typography-paragraph v-if="modalDescripcion" class="modal-desc">
{{ modalDescripcion }}
</a-typography-paragraph>
<div v-if="modalContenido" class="modal-content" v-html="modalContenido" />
<a-empty v-else description="No hay contenido disponible para esta noticia." />
<div v-if="modalLinkUrl" class="modal-link">
<a-button
type="primary"
:href="modalLinkUrl"
target="_blank"
rel="noopener"
>
{{ modalLinkTexto }}
</a-button>
</div>
</div>
</a-modal>
</div>
</section>
</template>
<script setup>
import { CalendarOutlined, RightOutlined, ArrowRightOutlined } from "@ant-design/icons-vue"
import { computed, onMounted, ref } from "vue"
import { CalendarOutlined, ArrowRightOutlined } from "@ant-design/icons-vue"
import { useNoticiasPublicasStore } from "../../store/noticiasPublicas.store"
const noticiasStore = useNoticiasPublicasStore()
onMounted(() => {
if (!noticiasStore.noticias.length) noticiasStore.fetchNoticias()
})
const mappedNoticias = computed(() => {
return (noticiasStore.noticias ?? []).map((n) => {
const titulo = n.titulo ?? "Sin título"
const descripcion = n.descripcion_corta ?? n.descripcion ?? "Sin descripción"
const imagen = n.imagen_url ?? null
return {
id: n.id,
slug: n.slug,
titulo,
descripcion,
imagen,
fecha: formatFecha(n.fecha_publicacion),
categoria: n.categoria,
tagColor: normalizeTagColor(n.tag_color),
destacado: !!n.destacado,
raw: n,
}
})
})
const modalOpen = ref(false)
const selectedNoticia = ref(null)
const modalTitle = computed(() => selectedNoticia.value?.titulo ?? "Detalle de noticia")
const detail = computed(() => noticiasStore.noticiaActual)
const modalImage = computed(() => {
const d = detail.value
return d?.imagen_url ?? selectedNoticia.value?.imagen ?? null
})
const modalFecha = computed(() => {
const d = detail.value
return formatFecha(d?.fecha_publicacion ?? selectedNoticia.value?.raw?.fecha_publicacion)
})
const modalCategoria = computed(() => {
return detail.value?.categoria ?? selectedNoticia.value?.categoria ?? "General"
})
const modalTagColor = computed(() => {
return normalizeTagColor(detail.value?.tag_color ?? selectedNoticia.value?.raw?.tag_color)
})
defineProps({
noticias: {
type: Array,
default: () => [],
},
const modalDescripcion = computed(() => {
const d = detail.value
return d?.descripcion_corta ?? selectedNoticia.value?.descripcion ?? ""
})
const modalContenido = computed(() => {
return detail.value?.contenido ?? ""
})
const modalLinkUrl = computed(() => detail.value?.link_url ?? null)
const modalLinkTexto = computed(() => detail.value?.link_texto ?? "Leer más")
const openModal = async (noticia) => {
selectedNoticia.value = noticia
modalOpen.value = true
noticiasStore.clearNoticiaActual()
const identifier = noticia.slug || noticia.id
await noticiasStore.fetchNoticia(identifier)
}
const onAfterClose = () => {
noticiasStore.clearNoticiaActual()
selectedNoticia.value = null
}
const formatFecha = (iso) => {
if (!iso) return "Sin fecha"
const d = new Date(iso)
if (isNaN(d.getTime())) return "Sin fecha"
return d.toLocaleDateString("es-PE", {
day: "2-digit",
month: "short",
year: "numeric",
})
}
const normalizeTagColor = (c) => {
const val = String(c || "").trim().toLowerCase()
const allowed = new Set([
"blue",
"red",
"green",
"orange",
"purple",
"cyan",
"gold",
"geekblue",
"magenta",
"lime",
"volcano",
"default",
])
return allowed.has(val) ? val : "blue"
}
</script>
<style scoped>
<style scoped>
.news-section {
position: relative;
padding: 88px 0;
@ -134,59 +305,43 @@ defineProps({
padding: 0 24px;
}
/* ===== Header ===== */
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
justify-content: center;
align-items: center;
margin-bottom: 18px;
}
.header-left {
width: 100%;
max-width: 820px;
text-align: center;
}
/* ===== Título centrado + Times New Roman ===== */
.title {
margin: 0 !important;
text-align: center;
font-family: "Lora", serif !important;
font-weight: 900; /* fuerte para título */
font-family: "Times New Roman", Times, serif !important;
font-weight: 900;
color: #111a56;
letter-spacing: -0.4px;
}
.subtitle {
margin: 8px 0 0 !important;
text-align: center;
font-family: "Lora", serif;
font-family: "Times New Roman", Times, serif;
font-weight: 300;
color: rgba(0, 0, 0, 0.58);
line-height: 1.6;
font-size: 1.02rem;
}
.title :deep(.ant-typography),
.subtitle :deep(.ant-typography) {
font-family: "Lora", serif !important;
}
.btn-all {
border-radius: 999px;
font-weight: 800;
padding: 0 18px;
height: 40px;
box-shadow: 0 10px 22px rgba(24, 144, 255, 0.18);
}
.divider {
margin: 18px 0 28px !important;
opacity: 0.6;
}
.card {
border: 0;
border-radius: 18px;
@ -202,17 +357,15 @@ defineProps({
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.12);
}
.card :deep(.ant-card-cover) {
margin: 0;
}
.cover {
position: relative;
height: 200px;
background-size: cover;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
background-color: rgba(17, 26, 86, 0.06);
}
.cover-overlay {
@ -225,7 +378,6 @@ defineProps({
);
}
.date-pill {
position: absolute;
left: 14px;
@ -243,9 +395,18 @@ defineProps({
font-weight: 700;
}
.content {
width: 100%;
.date-inline {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
width: fit-content;
background: rgba(17, 26, 86, 0.06);
border: 1px solid rgba(17, 26, 86, 0.12);
color: #111a56;
font-weight: 700;
font-size: 0.9rem;
}
.card-title {
@ -262,7 +423,6 @@ defineProps({
font-size: 0.98rem;
}
.actions {
display: flex;
justify-content: space-between;
@ -275,21 +435,78 @@ defineProps({
font-weight: 900;
}
.tag-soft {
border-radius: 999px;
font-weight: 700;
}
@media (max-width: 768px) {
.news-section {
padding: 64px 0;
}
.modal-cover {
width: 100%;
height: 360px;
overflow: hidden;
border-radius: 14px;
margin-bottom: 14px;
border: 1px solid rgba(17, 26, 86, 0.10);
background: rgba(17, 26, 86, 0.05);
display: flex;
align-items: center;
justify-content: center;
}
.header {
flex-direction: column;
.modal-cover img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.modal-meta {
display: flex;
align-items: center;
text-align: center;
gap: 10px;
margin: 8px 0 10px;
}
.btn-all {
margin-top: 6px;
.modal-date {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(0, 0, 0, 0.55);
font-weight: 700;
}
.modal-desc {
color: rgba(0, 0, 0, 0.70);
line-height: 1.7;
margin-bottom: 12px !important;
}
.modal-content {
line-height: 1.8;
color: rgba(0, 0, 0, 0.80);
}
.modal-content :deep(img) {
max-width: 100%;
height: auto;
display: block;
}
.modal-content :deep(p) {
margin: 0 0 12px;
}
.modal-content :deep(img) {
max-width: 100%;
border-radius: 12px;
}
.modal-link {
margin-top: 16px;
}
@media (max-width: 768px) {
.news-section {
padding: 64px 0;
}
}
</style>

@ -3,30 +3,111 @@
<section v-if="proceso" class="process-section" aria-labelledby="process-title">
<div class="section-container">
<div class="section-header">
<h2 id="process-title" class="section-title">{{ proceso.titulo || 'Proceso de Admisión 2026' }}</h2>
<h2 id="process-title" class="section-title">{{ tituloProceso }}</h2>
<p class="section-subtitle">
{{ proceso.subtitulo || 'Sigue estos pasos para postular' }}
¿No sabes por dónde empezar? Aquí te guiamos paso a paso y te decimos qué debes hacer hoy.
</p>
</div>
<div class="process-card">
<!-- STEPS -->
<a-steps
:current="currentStep"
:direction="isMobile ? 'vertical' : 'horizontal'"
:responsive="false"
class="modern-steps"
>
<a-step
v-for="step in steps"
:key="step.title"
:title="step.title"
:description="step.description"
:items="stepsItems"
/>
</a-steps>
<div class="process-note">
<!-- GUÍA PARA POSTULANTES -->
<div class="help-box" v-if="store.procesoPrincipal">
<div class="help-title"> Guía rápida (para no perderte)</div>
<div class="help-grid">
<!-- Etapas activas -->
<div class="help-item">
<div class="help-label">1) ¿Qué etapa está activa hoy?</div>
<div class="badges">
<span v-if="active.pre" class="badge badge-blue">
Preinscripción activa (virtual / en línea)
</span>
<span v-if="active.ins" class="badge badge-blue">
Inscripción activa (presencial en Campus Universitario)
</span>
<span v-if="active.exa" class="badge badge-green">
Hoy es el Examen
</span>
<span v-if="active.res" class="badge badge-green">
Hoy salen Resultados
</span>
<span v-if="active.bio" class="badge badge-orange">
Biométrico activo (solo ingresantes)
</span>
<span v-if="noActive" class="badge badge-gray">
Aún no inicia o ya terminó una etapa. Revisa las fechas del proceso.
</span>
</div>
<div class="tiny-hint">
Tip: Si ves 🟢 en una fecha, significa que esa etapa está activa hoy.
</div>
</div>
<!-- Qué hacer -->
<div class="help-item">
<div class="help-label">2) ¿Qué debo hacer ahora?</div>
<ul class="help-list">
<li v-for="(t, i) in tareasHoy" :key="i">{{ t }}</li>
</ul>
<div class="help-actions">
<!-- Preinscripción Virtual (solo si existe link) -->
<a-button
v-if="store.procesoPrincipal.link_preinscripcion"
type="primary"
:href="store.procesoPrincipal.link_preinscripcion"
target="_blank"
>
Iniciar Preinscripción
</a-button>
<a-button
v-if="store.procesoPrincipal.link_reglamento"
type="default"
:href="store.procesoPrincipal.link_reglamento"
target="_blank"
>
Ver Reglamento
</a-button>
<a-button
v-if="store.procesoPrincipal.link_resultados"
type="default"
:href="store.procesoPrincipal.link_resultados"
target="_blank"
>
Ver Resultados
</a-button>
</div>
</div>
</div>
<!-- Nota fija sobre inscripción presencial -->
<div class="campus-note">
<b>Importante:</b> La <b>Inscripción</b> se realiza de forma <b>presencial</b> en el
<b>Campus Universitario</b>. Lleva tu DNI y los requisitos solicitados.
</div>
</div>
<div class="process-note" v-else>
<span class="dot" />
<span>Fechas referenciales. Verifica el cronograma oficial de la Dirección de Admisión</span>
<span>Cargando proceso...</span>
</div>
</div>
</div>
@ -35,90 +116,243 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue"
import { useWebAdmisionStore } from "../../store/web"
const props = defineProps({
proceso: {
type: Object,
default: null,
},
})
const store = useWebAdmisionStore()
const isMobile = ref(false)
const checkScreen = () => {
isMobile.value = window.innerWidth < 768
}
const checkScreen = () => (isMobile.value = window.innerWidth < 768)
const now = ref(new Date())
let timer = null
onMounted(() => {
checkScreen()
window.addEventListener("resize", checkScreen)
if (!store.procesoPrincipal) store.cargarProcesos()
timer = setInterval(() => (now.value = new Date()), 60_000)
})
onUnmounted(() => {
window.removeEventListener("resize", checkScreen)
if (timer) clearInterval(timer)
})
function formatRango(inicio, fin) {
if (!inicio && !fin) return null
const opts = { day: 'numeric', month: 'short' }
const i = inicio ? new Date(inicio).toLocaleDateString('es-PE', opts) : null
const f = fin ? new Date(fin).toLocaleDateString('es-PE', opts) : null
if (i && f) return `${i} - ${f}`
if (i) return `Desde ${i}`
return `Hasta ${f}`
const toDate = (value) => {
if (!value) return null
const d = new Date(value)
return isNaN(d.getTime()) ? null : d
}
function formatFecha(fecha) {
if (!fecha) return null
return new Date(fecha).toLocaleDateString('es-PE', { day: 'numeric', month: 'long' })
const startOfDay = (d) => {
const x = new Date(d)
x.setHours(0, 0, 0, 0)
return x
}
const steps = computed(() => {
const p = props.proceso
if (!p) return []
const endOfDay = (d) => {
const x = new Date(d)
x.setHours(23, 59, 59, 999)
return x
}
const list = []
const inRange = (n, start, end) => {
if (!start || !end) return false
return n >= startOfDay(start) && n <= endOfDay(end)
}
const preinscripcion = formatRango(p.fecha_inicio_preinscripcion, p.fecha_fin_preinscripcion)
if (preinscripcion) {
list.push({ title: 'Preinscripción Virtual', description: preinscripcion })
}
const sameDay = (a, b) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
const fmtShort = (value) => {
const d = toDate(value)
if (!d) return "Por definir"
return d.toLocaleDateString("es-PE", { day: "2-digit", month: "short" })
}
const fmtLong = (value) => {
const d = toDate(value)
if (!d) return "Por definir"
return d.toLocaleDateString("es-PE", {
day: "2-digit",
month: "short",
year: "numeric",
})
}
const fmtRange = (start, end) => {
const s = toDate(start)
const e = toDate(end)
if (s && e) return `${fmtShort(s)} ${fmtShort(e)}`
if (s && !e) return fmtLong(s)
if (!s && e) return fmtLong(e)
return "Por definir"
}
const tituloProceso = computed(() => {
const p = store.procesoPrincipal
if (!p) return "Proceso de Admisión"
return p.titulo || p.tipo_proceso || "Proceso de Admisión"
})
const inscripcion = formatRango(p.fecha_inicio_inscripcion, p.fecha_fin_inscripcion)
if (inscripcion) {
list.push({ title: 'Inscripción Presencial', description: inscripcion })
}
const examen = formatRango(p.fecha_examen1, p.fecha_examen2)
if (examen) {
list.push({ title: 'Examen', description: examen })
const active = computed(() => {
const p = store.procesoPrincipal
if (!p) return { pre: false, ins: false, exa: false, res: false, bio: false }
const n = now.value
const preIni = toDate(p.fecha_inicio_preinscripcion)
const preFin = toDate(p.fecha_fin_inscripcion) || toDate(p.fecha_fin_preinscripcion)
const insIni = toDate(p.fecha_inicio_inscripcion)
const insFin = toDate(p.fecha_fin_inscripcion)
const exa = toDate(p.fecha_examen1)
const res = toDate(p.fecha_resultados)
const bioIni = toDate(p.fecha_inicio_biometrico)
const bioFin = toDate(p.fecha_fin_biometrico)
return {
pre: inRange(n, preIni, preFin),
ins: inRange(n, insIni, insFin),
exa: exa ? sameDay(n, exa) : false,
res: res ? sameDay(n, res) : false,
bio: inRange(n, bioIni, bioFin),
}
})
const resultados = formatFecha(p.fecha_resultados)
if (resultados) {
list.push({ title: 'Resultados', description: resultados })
const noActive = computed(() => {
const a = active.value
return !a.pre && !a.ins && !a.exa && !a.res && !a.bio
})
const getStepStatus = (index, p) => {
const n = now.value
const preIni = toDate(p.fecha_inicio_preinscripcion)
const preFin = toDate(p.fecha_fin_inscripcion) || toDate(p.fecha_fin_preinscripcion)
const insIni = toDate(p.fecha_inicio_inscripcion)
const insFin = toDate(p.fecha_fin_inscripcion)
const exa = toDate(p.fecha_examen1)
const res = toDate(p.fecha_resultados)
const bioIni = toDate(p.fecha_inicio_biometrico)
const bioFin = toDate(p.fecha_fin_biometrico)
const activePre = inRange(n, preIni, preFin)
const activeIns = inRange(n, insIni, insFin)
const activeExa = exa ? sameDay(n, exa) : false
const activeRes = res ? sameDay(n, res) : false
const activeBio = inRange(n, bioIni, bioFin)
const passed = (end) => (end ? n > endOfDay(end) : false)
const hasDates = [
Boolean(preIni || preFin),
Boolean(insIni || insFin),
Boolean(exa),
Boolean(res),
Boolean(bioIni || bioFin),
][index]
if (!hasDates) return "wait"
if (index === 0) return activePre ? "process" : passed(preFin) ? "finish" : "wait"
if (index === 1) return activeIns ? "process" : passed(insFin) ? "finish" : "wait"
if (index === 2) return activeExa ? "process" : passed(exa) ? "finish" : "wait"
if (index === 3) return activeRes ? "process" : passed(res) ? "finish" : "wait"
if (index === 4) return activeBio ? "process" : passed(bioFin) ? "finish" : "wait"
return "wait"
}
const withActiveBadge = (label, isActive) => (isActive ? `🟢 ${label}` : label)
const stepsItems = computed(() => {
const p = store.procesoPrincipal || {}
const a = active.value
return [
{
title: "Preinscripción Virtual",
description: withActiveBadge(
fmtRange(p.fecha_inicio_preinscripcion, p.fecha_fin_inscripcion),
a.pre
),
status: getStepStatus(0, p),
},
{
title: "Inscripción Presencial (Campus)",
description: withActiveBadge(
fmtRange(p.fecha_inicio_inscripcion, p.fecha_fin_inscripcion),
a.ins
),
status: getStepStatus(1, p),
},
{
title: "Examen",
description: withActiveBadge(fmtLong(p.fecha_examen1), a.exa),
status: getStepStatus(2, p),
},
{
title: "Resultados",
description: withActiveBadge(fmtLong(p.fecha_resultados), a.res),
status: getStepStatus(3, p),
},
{
title: "Control Biométrico (Ingresantes)",
description: withActiveBadge(
fmtRange(p.fecha_inicio_biometrico, p.fecha_fin_biometrico),
a.bio
),
status: getStepStatus(4, p),
},
]
})
const tareasHoy = computed(() => {
const p = store.procesoPrincipal
if (!p) return []
const a = active.value
const tareas = []
if (a.pre) {
tareas.push("Entra a la Preinscripción Virtual y completa tus datos sin apuro (verifica nombres y DNI).")
tareas.push("Al terminar, descarga e imprime tu solicitud de preinscripción y revisa los Requisitos del proceso.")
}
const biometrico = formatRango(p.fecha_inicio_biometrico, p.fecha_fin_biometrico)
if (biometrico) {
list.push({ title: 'Control Biométrico', description: biometrico })
if (a.ins) {
tareas.push("Acércate al Campus Universitario para la Inscripción Presencial.")
tareas.push("Lleva tu DNI y los documentos/pagos solicitados.")
}
return list
})
if (a.exa) {
tareas.push("Hoy es el Examen: llega temprano, lleva tu DNI, constancia de inscripción y sigue las indicaciones del reglamento.")
}
const now = new Date()
if (a.res) {
tareas.push("Hoy salen Resultados.")
}
const currentStep = computed(() => {
const p = props.proceso
if (!p) return 0
if (a.bio) {
tareas.push("Si ingresaste: acércate al control biométrico dentro de las fechas indicadas.")
}
if (p.fecha_inicio_biometrico && now >= new Date(p.fecha_inicio_biometrico)) return steps.value.length - 1
if (p.fecha_resultados && now >= new Date(p.fecha_resultados)) return steps.value.findIndex(s => s.title === 'Resultados')
if (p.fecha_examen1 && now >= new Date(p.fecha_examen1)) return steps.value.findIndex(s => s.title === 'Examen')
if (p.fecha_inicio_inscripcion && now >= new Date(p.fecha_inicio_inscripcion)) return steps.value.findIndex(s => s.title === 'Inscripción Presencial')
if (p.fecha_inicio_preinscripcion && now >= new Date(p.fecha_inicio_preinscripcion)) return steps.value.findIndex(s => s.title === 'Preinscripción Virtual')
if (tareas.length === 0) {
tareas.push("Revisa las fechas del proceso en los pasos de arriba.")
tareas.push("Si aún no inicia: alístate con requisitos, documentos y pagos para no correr a última hora.")
}
return 0
return tareas
})
</script>
@ -242,6 +476,105 @@ const currentStep = computed(() => {
flex-shrink: 0;
}
.help-box {
margin-top: 14px;
border-top: 1px dashed #e5e7eb;
padding-top: 12px;
}
.help-title {
font-weight: 700;
color: #1e3a8a;
margin-bottom: 10px;
}
.help-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.help-item {
border: 1px solid #eef2ff;
background: #fbfcff;
border-radius: 12px;
padding: 12px;
}
.help-label {
font-weight: 700;
color: #2c3e50;
margin-bottom: 8px;
}
.tiny-hint {
margin-top: 10px;
font-size: 0.86rem;
color: #6b7280;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.badge {
font-size: 0.82rem;
padding: 6px 10px;
border-radius: 999px;
font-weight: 700;
}
.badge-blue {
background: rgba(30, 58, 138, 0.08);
color: #1e3a8a;
border: 1px solid rgba(30, 58, 138, 0.18);
}
.badge-green {
background: rgba(16, 185, 129, 0.08);
color: #047857;
border: 1px solid rgba(16, 185, 129, 0.18);
}
.badge-orange {
background: rgba(245, 158, 11, 0.10);
color: #92400e;
border: 1px solid rgba(245, 158, 11, 0.22);
}
.badge-gray {
background: rgba(107, 114, 128, 0.08);
color: #374151;
border: 1px solid rgba(107, 114, 128, 0.18);
}
.help-list {
margin: 0;
padding-left: 18px;
color: #4b5563;
line-height: 1.55;
}
.help-actions {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
/* Nota campus */
.campus-note {
margin-top: 12px;
padding: 10px 12px;
border-radius: 12px;
background: #f8fafc;
border: 1px solid #e5e7eb;
color: #374151;
line-height: 1.5;
}
@media (max-width: 992px) {
.section-title {
font-size: 1.85rem;
@ -255,23 +588,22 @@ const currentStep = computed(() => {
.process-section {
padding: 24px 0;
}
.section-title {
font-size: 1.55rem;
}
.process-card {
padding: 12px 10px 10px;
}
.modern-steps {
padding: 4px 4px;
}
.modern-steps :deep(.ant-steps-item-icon) {
width: 28px;
height: 28px;
line-height: 28px;
}
.help-grid {
grid-template-columns: 1fr;
}
}
</style>

@ -1,4 +1,3 @@
<!-- components/programas/ProgramasSection.vue (SOLO ÁREAS, estilo AntDV) -->
<template>
<section class="areas-section">
<div class="section-container">

@ -1,9 +1,8 @@
<!-- components/stats/StatsSection.vue -->
<template>
<section class="stats-section" aria-labelledby="stats-title">
<div class="section-container">
<div class="section-header">
<h2 id="stats-title" class="section-title">Cifras Clave</h2>
<h2 id="stats-title" class="section-title">LA UNA PUNO EN CIFRAS</h2>
<p class="section-subtitle">
Indicadores institucionales que reflejan nuestro compromiso académico
</p>
@ -11,31 +10,35 @@
<div class="stats-grid">
<div class="stat-card">
<p class="stat-label">Fundada en</p>
<div class="stat-top">
<span class="stat-number">44+</span>
<span class="stat-number">1856</span>
</div>
<p class="stat-label">Carreras Profesionales</p>
</div>
<div class="stat-card">
<p class="stat-label">Facultades</p>
<div class="stat-top">
<span class="stat-number">98%</span>
<span class="stat-number">20</span>
</div>
<p class="stat-label">Índice de Satisfacción</p>
</div>
<div class="stat-card">
<p class="stat-label">Escuelas Profesionales</p>
<div class="stat-top">
<span class="stat-number">15,000+</span>
<span class="stat-number">39</span>
</div>
<p class="stat-label">Estudiantes Activos</p>
</div>
<div class="stat-card">
<p class="stat-label">Estudiantes Matriculados</p>
<div class="stat-top">
<span class="stat-number">85%</span>
<span class="stat-number">19,160</span>
</div>
<p class="stat-label">Egresados Laborando</p>
</div>
</div>
</div>
@ -43,7 +46,6 @@
</template>
<style scoped>
.stats-section {
position: relative;
padding: 70px 0;
@ -53,7 +55,6 @@
overflow: hidden;
}
.stats-section::before {
content: "";
position: absolute;
@ -79,7 +80,6 @@
);
}
.stats-section::after {
content: "";
position: absolute;
@ -121,20 +121,17 @@
line-height: 1.45;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.stat-card {
padding: 22px 18px;
border-radius: 16px;
text-align: center;
background: rgba(255, 255, 255, 0.10);
border: 1px solid rgba(255, 255, 255, 0.18);
backdrop-filter: blur(10px);
@ -156,7 +153,6 @@
gap: 6px;
}
.stat-number {
font-size: 2.8rem;
font-weight: 900;
@ -168,14 +164,12 @@
background-clip: text;
}
/* Texto */
.stat-label {
margin: 10px 0 0;
font-size: 1.02rem;
opacity: 0.92;
}
@media (max-width: 992px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));

@ -0,0 +1,124 @@
<template>
<NavbarModerno />
<section class="convocatorias-modern">
<div class="section-container">
<div class="section-header">
<div class="header-with-badge">
<h2 class="section-title">CEPREUNA</h2>
<a-badge count="Modalidad" class="new-badge" />
</div>
<p class="section-subtitle">
Examen de admisión del Centro Preuniversitario
</p>
</div>
<a-card class="main-convocatoria-card">
<div class="card-badge">CEPREUNA</div>
<div class="convocatoria-header">
<div>
<h3>Examen de Admisión del Centro Preuniversitario</h3>
<p class="convocatoria-date">Convocatoria: una vez por semestre</p>
</div>
<!-- <a-tag class="status-tag" color="success">CEPREUNA</a-tag> -->
</div>
<a-alert
type="info"
show-icon
class="soft-alert"
message="Dirigido a estudiantes que concluyeron el quinto año de secundaria y cursaron el ciclo preparatorio del Centro Preuniversitario de la UNA-Puno."
/>
<a-divider class="custom-divider" />
<a-card class="soft-card" size="small">
<h4 class="subheading">Evaluación</h4>
<p class="text">
El postulante rinde un examen de conocimientos con contenidos alineados al perfil del ingresante
establecido por la UNA-Puno, en la fecha indicada en el cronograma de admisión.
</p>
</a-card>
<a-card class="soft-card" size="small">
<h4 class="subheading">Requisitos y documentos</h4>
<a-list size="small" :split="false" class="info-list">
<a-list-item>
Presentar la constancia de no adeudar al CEPREUNA y DNI vigente.
</a-list-item>
<a-list-item>
Presentar los documentos exigidos por el reglamento para esta modalidad (según requisitos generales).
</a-list-item>
</a-list>
</a-card>
</a-card>
</div>
</section>
<FooterModerno />
</template>
<script setup>
import NavbarModerno from '../../nabvar.vue'
import FooterModerno from '../../Footer.vue'
</script>
<style scoped>
/* Diseño tipo Convocatorias (sin mayúsculas globales) */
.convocatorias-modern {
position: relative;
padding: 40px 0;
font-family: "Times New Roman", Times, serif;
background: #fbfcff;
overflow: hidden;
}
.convocatorias-modern::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
repeating-linear-gradient(to right, rgba(13, 27, 82, 0.06) 0, rgba(13, 27, 82, 0.06) 1px, transparent 1px, transparent 24px),
repeating-linear-gradient(to bottom, rgba(13, 27, 82, 0.06) 0, rgba(13, 27, 82, 0.06) 1px, transparent 1px, transparent 24px);
opacity: 0.55;
}
.section-container { position: relative; z-index: 1; max-width: 1200px; margin: 0 auto; padding: 0 24px; }
.section-header { text-align: center; margin-bottom: 40px; }
.header-with-badge { display: inline-flex; align-items: center; justify-content: center; gap: 14px; }
.section-title { font-size: 2.4rem; font-weight: 700; color: #0d1b52; margin: 0; }
.section-subtitle { font-size: 1.125rem; color: #666; max-width: 760px; margin: 14px auto 0; }
.new-badge :deep(.ant-badge-count) {
background: linear-gradient(45deg, #ff6b6b, #ffd700);
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
border-radius: 999px;
}
.main-convocatoria-card { position: relative; border: none; box-shadow: 0 10px 34px rgba(0,0,0,0.08); border-radius: 16px; }
.main-convocatoria-card :deep(.ant-card-body) { padding: 28px; }
.card-badge {
position: absolute; top: -12px; left: 24px;
background: linear-gradient(45deg, #1890ff, #52c41a);
color: white; padding: 6px 16px; border-radius: 999px;
font-size: 0.75rem; font-weight: 700;
}
.convocatoria-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 14px; margin-bottom: 14px; }
.convocatoria-header h3 { margin: 0; font-size: 1.55rem; color: #1a237e; }
.convocatoria-date { color: #666; margin: 6px 0 0; font-size: 0.95rem; }
.status-tag { font-weight: 700; padding: 4px 12px; border-radius: 999px; white-space: nowrap; }
.custom-divider { margin: 18px 0; }
.soft-alert { border-radius: 12px; }
.soft-card { border-radius: 12px; border: 1px solid rgba(0,0,0,0.06); margin-top: 12px; }
.subheading { margin: 0 0 6px; color: #1a237e; font-weight: 700; }
.text { margin: 0; color: #666; line-height: 1.7; }
.info-list :deep(.ant-list-item) { padding: 8px 0; border: none; color: #666; }
</style>

@ -0,0 +1,141 @@
<template>
<NavbarModerno/>
<section class="convocatorias-modern">
<div class="section-container">
<div class="section-header">
<div class="header-with-badge">
<h2 class="section-title">Extraordinario</h2>
<a-badge count="Modalidades" class="new-badge" />
</div>
<p class="section-subtitle">
Examen convocado una vez al año con varias formas de postulación
</p>
</div>
<a-card class="main-convocatoria-card">
<div class="card-badge">Extraordinario</div>
<div class="convocatoria-header">
<div>
<h3>Examen de Admisión Extraordinario</h3>
<p class="convocatoria-date">Convocatoria: una vez al año</p>
</div>
<!-- <a-tag class="status-tag" color="orange">Extraordinario</a-tag> -->
</div>
<a-card class="soft-card" size="small">
<h4 class="subheading">Evaluación</h4>
<p class="text">
El postulante rinde un examen de conocimientos con temas y contenidos alineados al perfil del ingresante,
en la fecha prevista en el cronograma de admisión.
</p>
</a-card>
<a-divider class="custom-divider" />
<h4 class="subheading" style="margin: 0 0 10px;">Formas de postulación</h4>
<a-collapse expand-icon-position="end" class="collapse-clean">
<a-collapse-panel key="a" header="Primeros puestos / COAR">
<p class="text">
Dirigido a estudiantes que obtuvieron los primeros lugares del orden de mérito en secundaria, con vigencia
definida por el reglamento; incluye egresados del COAR según corresponda.
</p>
<a-divider class="custom-divider" />
<a-list size="small" :split="false" class="info-list">
<a-list-item>Comprobantes de pago por admisión y carpeta de postulante.</a-list-item>
<a-list-item>Documento de identidad (original y copia).</a-list-item>
<a-list-item>Examen médico y aptitud vocacional solo para programas que lo exigen.</a-list-item>
<a-list-item>Solicitud generada tras la preinscripción virtual.</a-list-item>
<a-list-item>Certificados que acrediten estudios y condición de mérito según corresponda.</a-list-item>
</a-list>
</a-collapse-panel>
<a-collapse-panel key="b" header="Graduados o titulados">
<a-list size="small" :split="false" class="info-list">
<a-list-item>Comprobantes de pago por admisión y carpeta.</a-list-item>
<a-list-item>Documento de identidad (original y copia).</a-list-item>
<a-list-item>Solicitud generada tras la preinscripción virtual.</a-list-item>
<a-list-item>Grado o título certificado por la institución de origen.</a-list-item>
<a-list-item>Para extranjeros: legalizaciones requeridas según normativa.</a-list-item>
<a-list-item>Constancias de institución de origen o verificación según corresponda.</a-list-item>
</a-list>
</a-collapse-panel>
<a-collapse-panel key="c" header="Traslado interno">
<a-list size="small" :split="false" class="info-list">
<a-list-item>Comprobantes de pago por admisión y carpeta.</a-list-item>
<a-list-item>Documento de identidad (original y copia).</a-list-item>
<a-list-item>Solicitud indicando programa de origen y programa al que postula, según afinidad.</a-list-item>
<a-list-item>Historial académico y constancias de matrícula según requisitos establecidos.</a-list-item>
</a-list>
</a-collapse-panel>
<a-collapse-panel key="d" header="Traslado externo (nacional o internacional)">
<a-list size="small" :split="false" class="info-list">
<a-list-item>Comprobantes de pago por admisión y carpeta.</a-list-item>
<a-list-item>Documento de identidad (DNI / carné de extranjería / pasaporte) según corresponda.</a-list-item>
<a-list-item>Solicitud de postulación al mismo programa de estudio.</a-list-item>
<a-list-item>Certificados de estudios visados; en internacional, requisitos legales adicionales.</a-list-item>
<a-list-item>Constancia de matrícula vigente del semestre anterior o similar.</a-list-item>
</a-list>
</a-collapse-panel>
<a-collapse-panel key="e" header="Deportistas destacados">
<a-list size="small" :split="false" class="info-list">
<a-list-item>Comprobantes de pago por admisión y carpeta.</a-list-item>
<a-list-item>Documento de identidad (original y copia).</a-list-item>
<a-list-item>Resolución y récord deportivo documentado conforme a la normativa aplicable.</a-list-item>
<a-list-item>Certificado de estudios secundarios.</a-list-item>
</a-list>
</a-collapse-panel>
<a-collapse-panel key="f" header="Beneficiarios del Plan Integral de Reparaciones (PIR)">
<a-list size="small" :split="false" class="info-list">
<a-list-item>Solicitud generada tras la preinscripción virtual.</a-list-item>
<a-list-item>Documento de identidad (original y copia).</a-list-item>
<a-list-item>Constancias de registro correspondientes (RUV/REBRED u otras).</a-list-item>
<a-list-item>Examen médico y aptitud vocacional solo para programas que lo exigen.</a-list-item>
<a-list-item>Certificado de estudios secundarios.</a-list-item>
</a-list>
</a-collapse-panel>
</a-collapse>
</a-card>
</div>
</section>
<FooterModerno/>
</template>
<script setup>
import NavbarModerno from '../../nabvar.vue'
import FooterModerno from '../../Footer.vue'
</script>
<style scoped>
/* mismo estilo base del componente CEPREUNA */
.convocatorias-modern{position:relative;padding:40px 0;font-family:"Times New Roman",Times,serif;background:#fbfcff;overflow:hidden;}
.convocatorias-modern::before{content:"";position:absolute;inset:0;pointer-events:none;z-index:0;background-image:repeating-linear-gradient(to right,rgba(13,27,82,.06) 0,rgba(13,27,82,.06) 1px,transparent 1px,transparent 24px),repeating-linear-gradient(to bottom,rgba(13,27,82,.06) 0,rgba(13,27,82,.06) 1px,transparent 1px,transparent 24px);opacity:.55;}
.section-container{position:relative;z-index:1;max-width:1200px;margin:0 auto;padding:0 24px;}
.section-header{text-align:center;margin-bottom:40px;}
.header-with-badge{display:inline-flex;align-items:center;justify-content:center;gap:14px;}
.section-title{font-size:2.4rem;font-weight:700;color:#0d1b52;margin:0;}
.section-subtitle{font-size:1.125rem;color:#666;max-width:860px;margin:14px auto 0;}
.new-badge :deep(.ant-badge-count){background:linear-gradient(45deg,#ff6b6b,#ffd700);box-shadow:0 2px 8px rgba(255,107,107,.3);border-radius:999px;}
.main-convocatoria-card{position:relative;border:none;box-shadow:0 10px 34px rgba(0,0,0,.08);border-radius:16px;}
.main-convocatoria-card :deep(.ant-card-body){padding:28px;}
.card-badge{position:absolute;top:-12px;left:24px;background:linear-gradient(45deg,#1890ff,#52c41a);color:#fff;padding:6px 16px;border-radius:999px;font-size:.75rem;font-weight:700;}
.convocatoria-header{display:flex;justify-content:space-between;align-items:flex-start;gap:14px;margin-bottom:14px;}
.convocatoria-header h3{margin:0;font-size:1.55rem;color:#1a237e;}
.convocatoria-date{color:#666;margin:6px 0 0;font-size:.95rem;}
.status-tag{font-weight:700;padding:4px 12px;border-radius:999px;white-space:nowrap;}
.custom-divider{margin:18px 0;}
.soft-card{border-radius:12px;border:1px solid rgba(0,0,0,0.06);margin-top:12px;}
.subheading{margin:0 0 6px;color:#1a237e;font-weight:700;}
.text{margin:0;color:#666;line-height:1.7;}
.info-list :deep(.ant-list-item){padding:8px 0;border:none;color:#666;}
.collapse-clean :deep(.ant-collapse-item){border-radius:12px;overflow:hidden;margin-bottom:10px;border:1px solid rgba(0,0,0,0.06);}
.collapse-clean :deep(.ant-collapse-header){font-weight:700;color:#1a237e;}
</style>

@ -0,0 +1,99 @@
<template>
<NavbarModerno />
<section class="convocatorias-modern">
<div class="section-container">
<div class="section-header">
<div class="header-with-badge">
<h2 class="section-title">Admisión General</h2>
<a-badge count="Semestral" class="new-badge" />
</div>
<p class="section-subtitle">
Modalidad dirigida a egresados de secundaria (incluye postulantes con discapacidad acreditada)
</p>
</div>
<a-card class="main-convocatoria-card">
<div class="card-badge">General</div>
<div class="convocatoria-header">
<div>
<h3>Examen de Admisión General</h3>
<p class="convocatoria-date">Convocatoria: una vez por semestre</p>
</div>
<!--
<a-tag class="status-tag" color="blue">General</a-tag> -->
</div>
<a-alert
type="info"
show-icon
class="soft-alert"
message="Incluye postulantes con discapacidad debidamente acreditados mediante su certificado correspondiente."
/>
<a-divider class="custom-divider" />
<a-card class="soft-card" size="small">
<h4 class="subheading">A quién está dirigido</h4>
<p class="text">
Dirigido a estudiantes egresados que hayan concluido educación secundaria en EBR, EBA y COAR.
</p>
</a-card>
<a-card class="soft-card" size="small">
<h4 class="subheading">Evaluación</h4>
<p class="text">
Examen de conocimientos basado en contenidos alineados al perfil del ingresante, según el cronograma de admisión.
</p>
</a-card>
<a-card class="soft-card" size="small">
<h4 class="subheading">Asignación de vacantes</h4>
<p class="text">
Se asignan por puntaje hasta completar el número ofertado por los programas de estudio, conforme al reglamento.
</p>
</a-card>
<a-card class="soft-card" size="small">
<h4 class="subheading">Documentación</h4>
<p class="text">
El postulante debe presentar los documentos exigidos por el reglamento para esta modalidad.
</p>
</a-card>
</a-card>
</div>
</section>
<FooterModerno/>
</template>
<script setup>
import NavbarModerno from '../../nabvar.vue'
import FooterModerno from '../../Footer.vue'
</script>
<style scoped>
/* mismo estilo base */
.convocatorias-modern{position:relative;padding:40px 0;font-family:"Times New Roman",Times,serif;background:#fbfcff;overflow:hidden;}
.convocatorias-modern::before{content:"";position:absolute;inset:0;pointer-events:none;z-index:0;background-image:repeating-linear-gradient(to right,rgba(13,27,82,.06) 0,rgba(13,27,82,.06) 1px,transparent 1px,transparent 24px),repeating-linear-gradient(to bottom,rgba(13,27,82,.06) 0,rgba(13,27,82,.06) 1px,transparent 1px,transparent 24px);opacity:.55;}
.section-container{position:relative;z-index:1;max-width:1200px;margin:0 auto;padding:0 24px;}
.section-header{text-align:center;margin-bottom:40px;}
.header-with-badge{display:inline-flex;align-items:center;justify-content:center;gap:14px;}
.section-title{font-size:2.4rem;font-weight:700;color:#0d1b52;margin:0;}
.section-subtitle{font-size:1.125rem;color:#666;max-width:860px;margin:14px auto 0;}
.new-badge :deep(.ant-badge-count){background:linear-gradient(45deg,#ff6b6b,#ffd700);box-shadow:0 2px 8px rgba(255,107,107,.3);border-radius:999px;}
.main-convocatoria-card{position:relative;border:none;box-shadow:0 10px 34px rgba(0,0,0,.08);border-radius:16px;}
.main-convocatoria-card :deep(.ant-card-body){padding:28px;}
.card-badge{position:absolute;top:-12px;left:24px;background:linear-gradient(45deg,#1890ff,#52c41a);color:#fff;padding:6px 16px;border-radius:999px;font-size:.75rem;font-weight:700;}
.convocatoria-header{display:flex;justify-content:space-between;align-items:flex-start;gap:14px;margin-bottom:14px;}
.convocatoria-header h3{margin:0;font-size:1.55rem;color:#1a237e;}
.convocatoria-date{color:#666;margin:6px 0 0;font-size:.95rem;}
.status-tag{font-weight:700;padding:4px 12px;border-radius:999px;white-space:nowrap;}
.custom-divider{margin:18px 0;}
.soft-alert{border-radius:12px;}
.soft-card{border-radius:12px;border:1px solid rgba(0,0,0,0.06);margin-top:12px;}
.subheading{margin:0 0 6px;color:#1a237e;font-weight:700;}
.text{margin:0;color:#666;line-height:1.7;}
</style>

@ -0,0 +1,411 @@
<script setup>
import { ref, computed, onMounted } from "vue"
import axios from "axios"
import NavbarModerno from '../../nabvar.vue'
import FooterModerno from '../../Footer.vue'
import {
CalendarOutlined,
FileSearchOutlined,
} from "@ant-design/icons-vue"
const procesos = ref([])
const loading = ref(false)
const errorMsg = ref("")
const yaPasoFechaExamen = (fec_2, fec_1) => {
if (!fec_2 && !fec_1) return true
const fechaReferencia = fec_2 || fec_1
const fecha = new Date(fechaReferencia)
if (Number.isNaN(fecha.getTime())) return true
fecha.setDate(fecha.getDate() + 1)
const hoy = new Date()
return hoy >= fecha
}
const formatFecha = (value) => {
if (!value) return ""
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ""
return d.toLocaleDateString("es-PE", { year: "numeric", month: "short", day: "2-digit" })
}
onMounted(async () => {
loading.value = true
errorMsg.value = ""
try {
const response = await axios.get("https://inscripciones.admision.unap.edu.pe/api/get-procesos")
if (response.data?.estado) {
procesos.value = Array.isArray(response.data?.res) ? response.data.res : []
} else {
procesos.value = []
errorMsg.value = "La API respondió en un formato inesperado."
}
} catch (error) {
procesos.value = []
errorMsg.value = "Error al cargar procesos."
console.error("Error al cargar procesos:", error)
} finally {
loading.value = false
}
})
const procesosAgrupados = computed(() => {
const agrupado = {}
for (const proceso of procesos.value) {
const anio = proceso?.anio ?? "Sin año"
if (!agrupado[anio]) agrupado[anio] = []
agrupado[anio].push(proceso)
}
return agrupado
})
const aniosOrdenados = computed(() => {
return Object.keys(procesosAgrupados.value).sort((a, b) => Number(b) - Number(a))
})
/** Solo resultados (examen ya pasó) */
const resultadosPorAnio = computed(() => {
const out = {}
for (const anio of aniosOrdenados.value) {
out[anio] = (procesosAgrupados.value[anio] || []).filter((p) =>
yaPasoFechaExamen(p?.fec_2, p?.fec_1)
)
}
return out
})
const hayResultados = computed(() =>
aniosOrdenados.value.some((anio) => (resultadosPorAnio.value[anio] || []).length > 0)
)
const linkResultados = (p) => `https://inscripciones.admision.unap.edu.pe/${p?.slug}/resultados`
const estadoTag = (p) => {
return yaPasoFechaExamen(p?.fec_2, p?.fec_1)
? { text: "FINALIZADO", color: "default" }
: { text: "PRÓXIMAMENTE", color: "orange" }
}
</script>
<template>
<NavbarModerno />
<section id="resultados" class="convocatorias-modern">
<div class="section-container">
<div class="section-header">
<div class="header-with-badge">
<h2 class="section-title">Resultados</h2>
</div>
<p class="section-subtitle">
Consulta los resultados por año y proceso. Solo se muestran cuando el examen ya pasó.
</p>
</div>
<!-- Estados -->
<a-card class="main-convocatoria-card" :loading="loading">
<div class="card-badge">Resultados</div>
<template v-if="errorMsg">
<div style="padding: 6px 2px; color: #dc2626; font-weight: 700;">
{{ errorMsg }}
</div>
</template>
<template v-else-if="!hayResultados && !loading">
<div style="padding: 6px 2px; color: #666;">
Aún no hay resultados disponibles para mostrar.
</div>
</template>
<template v-else>
<div style="padding: 6px 2px; color:#666;">
Resultados disponibles organizados por año:
</div>
</template>
</a-card>
<!-- CADA AÑO ES SU PROPIA SECCIÓN / CARD -->
<div v-for="anio in aniosOrdenados" :key="anio">
<a-card
v-if="(resultadosPorAnio[anio] || []).length"
class="year-section-card"
>
<div class="year-header">
<div class="year-icon">
<CalendarOutlined />
</div>
<div>
<h3 class="year-title">Año {{ anio }}</h3>
<p class="year-subtitle">Procesos con resultados disponibles del {{ anio }}.</p>
</div>
</div>
<a-divider class="custom-divider" />
<!-- UNA SOLA COLUMNA -->
<div class="secondary-list one-col">
<a-card
v-for="proceso in resultadosPorAnio[anio]"
:key="proceso.id"
class="secondary-convocatoria-card"
>
<div class="convocatoria-header">
<div>
<h4 class="secondary-title">
EXAMEN {{ (proceso?.nombre ?? "proceso").toLowerCase() }}
</h4>
<p class="convocatoria-date">
Examen:
{{ formatFecha(proceso?.fec_2 || proceso?.fec_1) || (proceso?.fecha_examen ) }}
</p>
</div>
<a-tag class="status-tag" :color="estadoTag(proceso).color">
{{ estadoTag(proceso).text }}
</a-tag>
</div>
<div class="card-footer">
<a-button type="primary" ghost size="small" :href="linkResultados(proceso)" target="_blank">
<template #icon><FileSearchOutlined /></template>
Ver Resultados
</a-button>
</div>
</a-card>
</div>
</a-card>
</div>
</div>
</section>
<FooterModerno />
</template>
<style scoped>
/* ====== BASE CONVOCATORIAS ====== */
.convocatorias-modern {
position: relative;
padding: 40px 0;
font-family: "Times New Roman", Times, serif;
background: #fbfcff;
overflow: hidden;
}
.convocatorias-modern::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
repeating-linear-gradient(
to right,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 24px
),
repeating-linear-gradient(
to bottom,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 24px
);
opacity: 0.55;
}
.section-container {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
.section-header {
text-align: center;
margin-bottom: 50px;
}
.header-with-badge {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 14px;
}
.section-title {
font-size: 2.6rem;
font-weight: 700;
color: #0d1b52;
margin: 0;
}
.section-subtitle {
font-size: 1.125rem;
color: #666;
max-width: 640px;
margin: 14px auto 0;
}
.new-badge :deep(.ant-badge-count) {
background: linear-gradient(45deg, #ff6b6b, #ffd700);
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
border-radius: 999px;
}
/* Card “estado” */
.main-convocatoria-card {
position: relative;
border: none;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.08);
border-radius: 16px;
margin-bottom: 18px;
}
.main-convocatoria-card :deep(.ant-card-body) {
padding: 28px;
}
.card-badge {
position: absolute;
top: -12px;
left: 24px;
background: linear-gradient(45deg, #1890ff, #52c41a);
color: white;
padding: 6px 16px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
}
/* ✅ CARD POR AÑO */
.year-section-card {
border: none;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.07);
border-radius: 16px;
margin-top: 18px;
}
.year-section-card :deep(.ant-card-body) {
padding: 22px;
}
.year-header {
display: flex;
align-items: center;
gap: 14px;
}
.year-icon {
width: 44px;
height: 44px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(24, 144, 255, 0.12);
color: #1890ff;
font-size: 18px;
}
.year-title {
margin: 0;
font-size: 1.35rem;
color: #1a237e;
font-weight: 700;
font-family: "Times New Roman", Times, serif;
}
.year-subtitle {
margin: 4px 0 0;
color: #666;
font-size: 0.95rem;
}
.custom-divider {
margin: 18px 0;
}
/* Cards internas */
.secondary-convocatoria-card {
border: none;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
border-radius: 12px;
}
.secondary-convocatoria-card :deep(.ant-card-body) {
padding: 18px;
}
.convocatoria-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 14px;
margin-bottom: 14px;
}
.secondary-title {
margin: 0;
font-size: 1.05rem;
color: #1a237e;
text-transform: uppercase;
font-family: "Times New Roman", Times, serif;
}
.convocatoria-date {
color: #666;
margin: 6px 0 0;
font-size: 0.95rem;
}
.status-tag {
font-weight: 700;
padding: 4px 12px;
border-radius: 999px;
white-space: nowrap;
}
.convocatoria-desc {
color: #666;
line-height: 1.6;
margin: 0 0 18px;
}
.card-footer {
display: flex;
align-items: center;
margin-top: 12px;
}
.card-footer > :last-child {
margin-left: auto; /* 👉 empuja el último a la derecha */
}
/* ✅ UNA SOLA COLUMNA SIEMPRE */
.secondary-list.one-col {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
}
@media (max-width: 992px) {
.section-title { font-size: 2.1rem; }
}
@media (max-width: 768px) {
.convocatorias-modern { padding: 55px 0; }
}
</style>

@ -22,7 +22,7 @@
/>
</nav>
<div class="right-actions desktop-only">
<!-- <div class="right-actions desktop-only">
<router-link
v-if="!authStore.isAuthenticated"
to="/login-postulante"
@ -40,7 +40,7 @@
Mi Portal
</a-button>
</router-link>
</div>
</div> -->
<a-button class="mobile-menu-btn mobile-only" type="text" @click="drawerOpen = true">
@ -81,7 +81,7 @@
:openKeys="mobileOpenKeys"
/>
<div class="drawer-auth">
<!-- <div class="drawer-auth">
<router-link
v-if="!authStore.isAuthenticated"
to="/login-postulante"
@ -105,7 +105,7 @@
Mi Portal
</a-button>
</router-link>
</div>
</div> -->
</div>
</a-drawer>
</a-layout-header>
@ -144,14 +144,14 @@ const navItems = computed(() => [
{ key: "sociales", label: "Sociales" },
],
},
{ key: "procesos", label: "Procesos" },
{
key: "modalidades",
label: "Modalidades",
children: [
{ key: "ordinario", label: "Ordinario" },
{ key: "cepreuna", label: "Cepreuna" },
{ key: "extraordinario", label: "Extraordinario" },
{ key: "sedes", label: "Sedes" },
{ key: "general", label: "General" },
],
},
{ key: "resultados", label: "Resultados" },
@ -165,15 +165,14 @@ watch(drawerOpen, (open) => {
const routesByKey = {
inicio: "/",
programas: "/programas",
ingenierias: "/programas/ingenierias",
biomedicas: "/programas/biomedicas",
sociales: "/programas/sociales",
procesos: "/procesos",
programas: "",
ingenierias: "",
biomedicas: "",
sociales: "",
modalidades: "/modalidades",
ordinario: "/modalidades/ordinario",
cepreuna: "/modalidades/cepreuna",
extraordinario: "/modalidades/extraordinario",
sedes: "/modalidades/sedes",
general: "/modalidades/general",
resultados: "/resultados",
}

@ -1,15 +1,16 @@
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Login from '../views/Login.vue'
import Hello from '../components/WebPage.vue'
import WebPage from '../components/WebPage.vue'
import NotFound from '../views/NotFound.vue'
import { useUserStore } from '../store/user'
import { useAuthStore as usePostulanteStore } from '../store/postulanteStore'
const routes = [
{ path: '/', component: Hello },
{ path: '/', component: WebPage },
{ path: '/login', component: Login, meta: { guest: true } },
{ path: '/account/auth/login', component: Login, meta: { guest: true } },
{
path: '/login-postulante',
@ -18,6 +19,35 @@ const routes = [
meta: { guest: true },
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound
},
{
path: '/resultados',
name: 'Resultados',
component: () => import('../components/WebPageSections/navbarcontent/Resultados.vue')
},
{
path: '/modalidades/cepreuna',
name: 'cepreuna',
component: () => import('../components/WebPageSections/navbarcontent/Cepreuna.vue')
},
{
path: '/modalidades/extraordinario',
name: 'extraordinario',
component: () => import('../components/WebPageSections/navbarcontent/Extraordinario.vue')
},
{
path: '/modalidades/general',
name: 'general',
component: () => import('../components/WebPageSections/navbarcontent/General.vue')
},
{
path: '/portal-postulante',
@ -127,7 +157,25 @@ 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')
},
{
path: '/admin/dashboard/noticias',
name: 'NoticiasAdmisionList',
component: () => import('../views/administrador/procesoadmision/NoticiasAdmin.vue')
}
]
},

@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import api from '../axiosPostulante'
export const useExamenStore = defineStore('examenStore', {
state: () => ({
procesos: [],
@ -9,14 +8,21 @@ export const useExamenStore = defineStore('examenStore', {
examenActual: null,
preguntas: [],
cargando: false,
calificando: false,
resultado: null,
error: null,
}),
actions: {
async fetchProcesos() {
try {
this.cargando = true
const { data } = await api.get('/examen/procesos')
this.procesos = data
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 {
@ -27,9 +33,7 @@ export const useExamenStore = defineStore('examenStore', {
async fetchAreas(proceso_id) {
try {
this.cargando = true
const { data } = await api.get('/examen/areas', {
params: { proceso_id }
})
const { data } = await api.get('/examen/areas', { params: { proceso_id } })
this.areas = data
} catch (e) {
this.error = e.response?.data?.message || e.message
@ -53,7 +57,6 @@ export const useExamenStore = defineStore('examenStore', {
}
},
async fetchExamenActual() {
try {
this.cargando = true
@ -94,12 +97,18 @@ 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
if (index !== -1 && data.success) {
this.preguntas[index].respuesta = respuesta
this.preguntas[index].es_correcta = data.correcta
this.preguntas[index].puntaje = data.puntaje
}
return data
} catch (e) {
this.error = e.response?.data?.message || e.message
@ -107,11 +116,58 @@ export const useExamenStore = defineStore('examenStore', {
}
},
async calificarExamen(examenId) {
try {
this.error = null
this.calificando = true
const { data } = await api.post(`/examen/${examenId}/calificar`)
if (data?.success) {
this.resultado = {
examen_id: data.examen_id,
proceso_id: data.proceso_id,
total_puntos: data.total_puntos,
total_correctas: data.total_correctas,
total_incorrectas: data.total_incorrectas,
total_nulas: data.total_nulas,
porcentaje_correctas: data.porcentaje_correctas,
calificacion_sobre_20: data.calificacion_sobre_20,
orden_merito: data.orden_merito,
correctas_por_curso: data.correctas_por_curso,
incorrectas_por_curso: data.incorrectas_por_curso,
preguntas_totales_por_curso: data.preguntas_totales_por_curso,
}
return data
}
this.error = data?.mensaje || data?.message || 'No se pudo calificar el examen.'
return { success: false, message: this.error }
} catch (e) {
const msg = e.response?.data?.mensaje || e.response?.data?.message || e.message
this.error = msg
return { success: false, message: msg, status: e.response?.status }
} finally {
this.calificando = false
}
},
async finalizarExamen(examenId) {
try {
const { data } = await api.post(`/examen/${examenId}/finalizar`)
if (data?.success) {
if (this.examenActual) {
this.examenActual.estado = 'finalizado'
this.examenActual.hora_fin = new Date().toISOString()
}
this.examenActual = null
this.preguntas = []
this.error = null
}
return data
} catch (e) {
this.error = e.response?.data?.message || e.message
@ -125,6 +181,8 @@ export const useExamenStore = defineStore('examenStore', {
this.examenActual = null
this.preguntas = []
this.cargando = false
this.calificando = false
this.resultado = null
this.error = null
}
}

@ -0,0 +1,49 @@
import { defineStore } from "pinia"
import api from "../axiosPostulante"
export const useNoticiasPublicasStore = defineStore("noticiasPublicas", {
state: () => ({
noticias: [],
noticiaActual: null,
loading: false,
loadingOne: false,
error: null,
}),
actions: {
async fetchNoticias() {
this.loading = true
this.error = null
try {
const res = await api.get("/noticias", {
params: { publicado: true, per_page: 9999 },
})
this.noticias = res.data?.data ?? []
} catch (err) {
this.error = err.response?.data?.message || "Error al cargar noticias"
this.noticias = []
} finally {
this.loading = false
}
},
async fetchNoticia(identifier) {
this.loadingOne = true
this.error = null
this.noticiaActual = null
try {
const res = await api.get(`/noticias/${identifier}`)
this.noticiaActual = res.data?.data ?? null
} catch (err) {
this.error = err.response?.data?.message || "Error al cargar la noticia"
this.noticiaActual = null
} finally {
this.loadingOne = false
}
},
clearNoticiaActual() {
this.noticiaActual = null
},
},
})

@ -0,0 +1,207 @@
// src/store/noticiasStore.js
import { defineStore } from "pinia"
import api from "../axios" // <-- cambia a tu axios (admin) si tienes otro
export const useNoticiasStore = defineStore("noticias", {
state: () => ({
noticias: [],
noticia: null,
loading: false,
saving: false,
deleting: false,
error: null,
// paginación (si tu back manda meta)
meta: {
current_page: 1,
last_page: 1,
per_page: 9,
total: 0,
},
// filtros
filters: {
publicado: null, // true/false/null
categoria: "",
q: "",
per_page: 9,
page: 1,
},
}),
getters: {
// para el público: solo publicadas (si ya las filtras en backend, esto es opcional)
publicadas: (state) => state.noticias.filter((n) => n.publicado),
// para ordenar en frontend (opcional)
ordenadas: (state) =>
[...state.noticias].sort((a, b) => {
const da = a.fecha_publicacion ? new Date(a.fecha_publicacion).getTime() : 0
const db = b.fecha_publicacion ? new Date(b.fecha_publicacion).getTime() : 0
return db - da
}),
},
actions: {
// ========= LISTAR =========
async cargarNoticias(extraParams = {}) {
this.loading = true
this.error = null
try {
const params = {
per_page: this.filters.per_page,
page: this.filters.page,
...extraParams,
}
if (this.filters.publicado !== null && this.filters.publicado !== "") {
params.publicado = this.filters.publicado ? 1 : 0
}
if (this.filters.categoria) params.categoria = this.filters.categoria
if (this.filters.q) params.q = this.filters.q
// Ruta agrupada:
// GET /api/administracion/noticias
const res = await api.get("/admin/noticias", { params })
this.noticias = res.data?.data ?? []
this.meta = res.data?.meta ?? this.meta
} catch (err) {
this.error = err.response?.data?.message || "Error al cargar noticias"
console.error(err)
} finally {
this.loading = false
}
},
// ========= VER 1 =========
async cargarNoticia(id) {
this.loading = true
this.error = null
try {
const res = await api.get(`/admin/noticias/${id}`)
this.noticia = res.data?.data ?? res.data
} catch (err) {
this.error = err.response?.data?.message || "Error al cargar la noticia"
console.error(err)
} finally {
this.loading = false
}
},
// ========= CREAR =========
// payload: { titulo, descripcion_corta, contenido, categoria, tag_color, fecha_publicacion, publicado, destacado, orden, link_url, link_texto, imagen(File) }
async crearNoticia(payload) {
this.saving = true
this.error = null
try {
const form = this._toFormData(payload)
const res = await api.post("/admin/noticias", form, {
headers: { "Content-Type": "multipart/form-data" },
})
const noticia = res.data?.data ?? null
if (noticia) {
// opcional: agrega arriba
this.noticias = [noticia, ...this.noticias]
}
return noticia
} catch (err) {
this.error = err.response?.data?.message || "Error al crear noticia"
console.error(err)
throw err
} finally {
this.saving = false
}
},
// ========= ACTUALIZAR =========
async actualizarNoticia(id, payload) {
this.saving = true
this.error = null
try {
// Si hay imagen (File), conviene multipart
const hasFile = payload?.imagen instanceof File
let res
if (hasFile) {
const form = this._toFormData(payload)
// Si tu ruta es PUT, axios con multipart PUT funciona.
res = await api.put(`/admin/noticias/${id}`, form, {
headers: { "Content-Type": "multipart/form-data" },
})
} else {
// JSON normal
res = await api.put(`/administracion/noticias/${id}`, payload)
}
const updated = res.data?.data ?? null
if (updated) {
this.noticias = this.noticias.map((n) => (n.id === id ? updated : n))
if (this.noticia?.id === id) this.noticia = updated
}
return updated
} catch (err) {
this.error = err.response?.data?.message || "Error al actualizar noticia"
console.error(err)
throw err
} finally {
this.saving = false
}
},
// ========= ELIMINAR =========
async eliminarNoticia(id) {
this.deleting = true
this.error = null
try {
await api.delete(`/admin/noticias/${id}`)
this.noticias = this.noticias.filter((n) => n.id !== id)
if (this.noticia?.id === id) this.noticia = null
return true
} catch (err) {
this.error = err.response?.data?.message || "Error al eliminar noticia"
console.error(err)
throw err
} finally {
this.deleting = false
}
},
// ========= Helpers =========
setFiltro(key, value) {
this.filters[key] = value
},
resetFiltros() {
this.filters = { publicado: null, categoria: "", q: "", per_page: 9, page: 1 }
},
_toFormData(payload = {}) {
const form = new FormData()
Object.entries(payload).forEach(([k, v]) => {
if (v === undefined || v === null) return
// booleans como 1/0 (Laravel feliz)
if (typeof v === "boolean") {
form.append(k, v ? "1" : "0")
return
}
form.append(k, v)
})
return form
},
},
})

@ -104,7 +104,7 @@ export const useUserStore = defineStore('user', {
console.error('Error en logout:', error)
} finally {
this.clearAuth()
router.push('/login')
router.push('/account/auth/login')
}
},
@ -125,7 +125,7 @@ export const useUserStore = defineStore('user', {
redirectByRole() {
if (!this.user || !this.user.roles?.length) {
router.push('/login')
router.push('/account/auth/login')
return
}

@ -0,0 +1,41 @@
// src/store/web.js
import { defineStore } from "pinia"
import api from "../axiosPostulante"
export const useWebAdmisionStore = defineStore("procesoAdmision", {
state: () => ({
procesos: [],
loading: false,
error: null,
}),
getters: {
// Si hay uno VIGENTE, úsalo como principal; si no, usa el primero.
procesoPrincipal: (state) => {
if (!state.procesos?.length) return null
return state.procesos.find((p) => p.estado === "publicado") ?? state.procesos[0]
},
// Por si lo necesitas después
ultimoProceso: (state) => {
return state.procesos?.length ? state.procesos[0] : null
},
},
actions: {
async cargarProcesos() {
this.loading = true
this.error = null
try {
const response = await api.get("/procesos-admision")
this.procesos = response.data?.data ?? response.data ?? []
} catch (err) {
this.error = err.response?.data?.message || "Error al cargar procesos"
console.error(err)
} finally {
this.loading = false
}
},
},
})

@ -1,10 +1,10 @@
<template>
<div class="login-container">
<div class="login-card">
<!-- Logo y título -->
<div class="login-header">
<div class="logo">
<!-- <img src="/logo.png" alt="Logo" /> -->
<img src="/logotiny.png" alt="Logo" />
</div>
<h2>{{ isRegister ? 'Crear Cuenta' : 'Iniciar Sesión' }}</h2>
<p class="subtitle">
@ -15,7 +15,6 @@
</p>
</div>
<!-- Formulario -->
<a-form
ref="formRef"
:model="formState"
@ -24,8 +23,7 @@
layout="vertical"
class="login-form"
>
<!-- Nombre (solo registro) -->
<!-- Nombre (solo registro) -->
<a-form-item v-if="isRegister" label="Nombre completo" name="name">
<a-input
v-model:value="formState.name"
@ -39,7 +37,7 @@
</a-input>
</a-form-item>
<!-- Email -->
<a-form-item label="Correo electrónico" name="email">
<a-input
v-model:value="formState.email"
@ -53,7 +51,7 @@
</a-input>
</a-form-item>
<!-- Contraseña -->
<a-form-item label="Contraseña" name="password">
<a-input-password
v-model:value="formState.password"
@ -67,7 +65,7 @@
</a-input-password>
</a-form-item>
<!-- Confirmar contraseña (solo registro) -->
<a-form-item v-if="isRegister" label="Confirmar contraseña" name="password_confirmation">
<a-input-password
v-model:value="formState.password_confirmation"
@ -81,7 +79,7 @@
</a-input-password>
</a-form-item>
<!-- Recordarme (solo login) -->
<div v-if="!isRegister" class="remember-forgot">
<a-checkbox v-model:checked="rememberMe">
Recordarme
@ -91,7 +89,6 @@
</a-button>
</div>
<!-- Botón principal -->
<a-form-item>
<a-button
type="primary"
@ -105,7 +102,6 @@
</a-button>
</a-form-item>
<!-- Cambiar modo -->
<div class="toggle-mode">
<span>
{{ isRegister
@ -118,7 +114,6 @@
</a-button>
</div>
<!-- Términos (solo registro) -->
<div v-if="isRegister" class="terms">
<p>
Al registrarse, acepta nuestros
@ -153,19 +148,18 @@ const formState = reactive({
password_confirmation: ''
})
// Configurar notificación para que aparezca en el centro superior
notification.config({
placement: 'top',
duration: 2,
maxCount: 1,
})
// Función para mostrar toast/notification
const showToast = (type, message, description = '') => {
const config = {
message,
description,
duration: 2, // 2 segundos
duration: 2,
placement: 'top',
}
@ -187,7 +181,6 @@ const showToast = (type, message, description = '') => {
}
}
// Reglas de validación
const rules = {
name: [
{
@ -237,18 +230,15 @@ const rules = {
]
}
// Cambiar entre login/registro
const toggleMode = () => {
isRegister.value = !isRegister.value
formRef.value?.resetFields()
}
// Olvidó contraseña
const handleForgotPassword = () => {
showToast('info', 'Recuperación de contraseña', 'Contacte al administrador del sistema')
}
// Enviar formulario
const handleSubmit = async () => {
try {
loading.value = true
@ -259,7 +249,7 @@ const handleSubmit = async () => {
'¡Registro exitoso!',
'Tu cuenta ha sido creada correctamente'
)
toggleMode() // Cambiar a login
toggleMode()
} else {
await userStore.login(formState.email, formState.password)
showToast('success', '¡Bienvenido!', 'Inicio de sesión exitoso')
@ -267,7 +257,7 @@ const handleSubmit = async () => {
} catch (error) {
console.error('Error:', error)
// Extraer mensaje de error del backend
let errorMessage = 'Ocurrió un error inesperado'
let errorDetails = ''
@ -317,7 +307,7 @@ const handleSubmit = async () => {
errorDetails = 'Verifique su conexión a internet'
}
// Mostrar error en toast
showToast('error', errorMessage, errorDetails)
} finally {

@ -0,0 +1,124 @@
<template>
<section class="modalidades-section">
<div class="section-container">
<div class="section-header">
<h1 class="section-title">404</h1>
<p class="section-subtitle">
La página que estás buscando no existe o fue movida.
</p>
</div>
<div class="modalidades-grid">
<a-card class="modalidad-card">
<div class="modalidad-icon bg-error">
<WarningOutlined />
</div>
<h4>Enlace incorrecto</h4>
<p>
Verifica que la dirección esté escrita correctamente
o regresa al inicio.
</p>
<a-button type="primary" @click="$router.push('/')">
Volver al Inicio
</a-button>
</a-card>
</div>
</div>
</section>
</template>
<script setup>
import { WarningOutlined } from "@ant-design/icons-vue"
</script>
<style scoped>
.modalidades-section {
padding: 100px 0;
background: #f8f9fa;
min-height: 100vh;
display: flex;
align-items: center;
}
.section-container {
max-width: 900px;
margin: 0 auto;
padding: 0 24px;
width: 100%;
}
.section-header {
text-align: center;
margin-bottom: 50px;
}
.section-title {
font-size: 5rem;
font-weight: 700;
color: #1a237e;
margin-bottom: 10px;
}
.section-subtitle {
font-size: 1.125rem;
color: #666;
}
.modalidades-grid {
display: flex;
justify-content: center;
}
.modalidad-card {
border: none;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
text-align: center;
padding: 40px 32px;
transition: transform 0.3s ease;
max-width: 420px;
width: 100%;
}
.modalidad-card:hover {
transform: translateY(-4px);
}
.modalidad-icon {
width: 72px;
height: 72px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24px;
color: white;
font-size: 32px;
}
.bg-error {
background: #ef4444;
}
.modalidad-card h4 {
margin: 0 0 12px;
color: #1a237e;
}
.modalidad-card p {
color: #666;
font-size: 0.95rem;
line-height: 1.6;
margin-bottom: 20px;
}
@media (max-width: 992px) {
.section-title {
font-size: 3rem;
}
}
</style>

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

@ -8,7 +8,6 @@
@cancel="handleCancel"
class="course-modal"
>
<!-- Barra de búsqueda -->
<div class="search-section">
<a-input-search
v-model:value="searchText"
@ -22,7 +21,6 @@
</a-input-search>
</div>
<!-- Lista de cursos -->
<div class="courses-container">
<a-spin :spinning="loading">
<div class="courses-list">
@ -124,15 +122,12 @@ const filteredCursos = computed(() => {
)
})
// Contador de cursos seleccionados
const selectedCursosCount = computed(() => selectedCursos.value.length)
// Verificar si un curso está seleccionado
const isCursoSelected = (cursoId) => {
return selectedCursos.value.includes(cursoId)
}
// Toggle de selección de curso
const toggleCurso = (cursoId) => {
const index = selectedCursos.value.indexOf(cursoId)
if (index > -1) {
@ -142,20 +137,17 @@ const toggleCurso = (cursoId) => {
}
}
// Buscar cursos
const handleSearch = () => {
// La búsqueda se maneja en computed filteredCursos
}
// Cancelar
const handleCancel = () => {
visible.value = false
selectedCursos.value = []
searchText.value = ''
areaStore.clearState() // Limpiar estado
areaStore.clearState()
}
// Guardar cambios
const handleSave = async () => {
try {
const result = await areaStore.vincularCursos(props.areaId, selectedCursos.value)
@ -171,7 +163,6 @@ const handleSave = async () => {
}
}
// Función para cargar cursos
const loadCursos = async () => {
if (props.areaId) {
await areaStore.fetchCursosPorArea(props.areaId)
@ -180,26 +171,21 @@ const loadCursos = async () => {
}
}
// Cargar cursos cuando se abre el modal
watch(() => props.open, async (newVal) => {
if (newVal && props.areaId) {
// Usar nextTick para asegurar que el modal esté montado
await nextTick()
await loadCursos()
}
})
// También cargar cuando cambia el áreaId (por si cambia mientras el modal está abierto)
watch(() => props.areaId, async (newVal) => {
if (props.open && newVal) {
await loadCursos()
}
})
// Limpiar al cerrar
watch(() => props.open, (newVal) => {
if (!newVal) {
// Pequeño delay para permitir que la animación de cierre termine
setTimeout(() => {
selectedCursos.value = []
searchText.value = ''
@ -208,7 +194,6 @@ watch(() => props.open, (newVal) => {
}
})
// También cargar cursos cuando el componente se monta si ya está abierto
onMounted(() => {
if (props.open && props.areaId) {
loadCursos()
@ -302,7 +287,6 @@ onMounted(() => {
border-top: 1px solid #f0f0f0;
}
/* Scrollbar personalizado */
.courses-container::-webkit-scrollbar {
width: 6px;
}

@ -8,7 +8,6 @@
@cancel="handleCancel"
class="process-modal"
>
<!-- Barra de búsqueda -->
<div class="search-section">
<a-input-search
v-model:value="searchText"
@ -22,7 +21,6 @@
</a-input-search>
</div>
<!-- Lista de procesos -->
<div class="processes-container">
<a-spin :spinning="loading">
<div v-if="procesosDisponibles.length === 0 && !loading" class="empty-state">
@ -69,7 +67,7 @@
</a-spin>
</div>
<!-- Resumen -->
<div class="summary-section">
<a-alert
:message="`${selectedProcesosCount} procesos seleccionados de ${procesosDisponibles.length} disponibles`"
@ -78,7 +76,6 @@
/>
</div>
<!-- Acciones -->
<div class="modal-footer">
<a-button @click="handleCancel">Cancelar</a-button>
<a-button type="primary" @click="handleSave" :loading="loading">
@ -125,7 +122,6 @@ const loading = computed(() => areaStore.loading)
const procesosDisponibles = computed(() => areaStore.procesosDisponibles || [])
const procesosVinculados = computed(() => areaStore.procesosVinculados || [])
/* ================= FILTRO ================= */
const filteredProcesos = computed(() => {
if (!searchText.value) return procesosDisponibles.value
@ -150,7 +146,6 @@ const toggleProceso = (id) => {
: selectedProcesos.value.push(id)
}
/* ================= ACCIONES ================= */
const handleCancel = () => {
visible.value = false
resetModal()
@ -172,7 +167,7 @@ const handleSave = async () => {
}
const handleSearch = () => {
// La búsqueda se maneja en computed filteredProcesos
}
const loadProcesos = async () => {
@ -187,7 +182,6 @@ const resetModal = () => {
selectedProcesos.value = []
}
/* ================= WATCHERS ================= */
watch(() => props.open, async (open) => {
if (open && props.areaId) {
await nextTick()
@ -201,14 +195,12 @@ watch(() => props.areaId, async (id) => {
}
})
// También cargar cursos cuando el componente se monta si ya está abierto
onMounted(() => {
if (props.open && props.areaId) {
loadProcesos()
}
})
// Limpiar al cerrar
watch(() => props.open, (newVal) => {
if (!newVal) {
setTimeout(() => {

@ -121,11 +121,23 @@ onMounted(() => {
list-style-type: decimal;
}
/* Fórmulas centradas */
.markdown-content :deep(.katex-display) {
margin: 1em 0;
margin: 0.75em 0;
text-align: center;
overflow-x: auto;
overflow-x: auto; /* si es largo, que haga scroll horizontal */
overflow-y: hidden; /* IMPORTANTE: no scroll vertical -> no flechas */
}
/* Quitar botones/flechas del scrollbar (Chrome/Edge) */
.markdown-content :deep(.katex-display::-webkit-scrollbar-button) {
display: none;
}
.markdown-content :deep(.katex-display::-webkit-scrollbar) {
height: 0px; /* Chrome/Edge */
}
/* Si quieres ocultar completamente la barra horizontal (opcional) */
.markdown-content :deep(.katex-display) {
scrollbar-width: none; /* Firefox */
}
.markdown-content :deep(.latex-error) {

File diff suppressed because it is too large Load Diff

@ -0,0 +1,193 @@
<template>
<div class="postulantes-container">
<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>
<a-card>
<a-table
:columns="columns"
:data-source="postulantes"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<strong>{{ record.name }}</strong>
</template>
<template v-else-if="column.key === 'email'">
{{ record.email }}
</template>
<template v-else-if="column.key === 'dni'">
<a-tag color="blue">{{ record.dni }}</a-tag>
</template>
<template v-else-if="column.key === 'last_activity'">
{{ record.last_activity ?? 'Sin actividad' }}
</template>
<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>
<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">
@ -139,7 +138,6 @@
</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,13 +197,19 @@
<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">
<template #title>
<div class="sub-menu-title">
<QuestionCircleOutlined class="menu-icon" />
<span class="menu-label">Procesos</span>
<span class="menu-label">WebConf</span>
</div>
</template>
<a-menu-item key="procesos-lista" class="menu-item">
@ -214,6 +218,12 @@
<span class="menu-label">Procesos Lista</span>
</div>
</a-menu-item>
<a-menu-item key="noticias-lista" class="menu-item">
<div class="menu-item-content">
<AppstoreOutlined class="menu-icon" />
<span class="menu-label">Noticias</span>
</div>
</a-menu-item>
</a-sub-menu>
@ -413,16 +423,15 @@ 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' },
'noticias-lista': { name: 'NoticiasAdmisionList' },
'resultados': { name: 'AcademiaResultados' },
'reportes': { name: 'AcademiaReportes' },
'config-academia': { name: 'AcademiaConfig' },
@ -496,7 +505,7 @@ const updatePageInfo = (key) => {
const logout = () => {
userStore.logout()
router.push('/login')
router.push('/account/auth/login')
}
const irPerfil = () => {

@ -0,0 +1,630 @@
<!-- src/views/administracion/noticias/NoticiasAdmin.vue -->
<template>
<div class="areas-container">
<!-- Header -->
<div class="areas-header">
<div class="header-title">
<h2>Noticias</h2>
<p class="subtitle">Gestión de noticias y comunicados</p>
</div>
<a-button type="primary" @click="showCreateModal">
<template #icon><PlusOutlined /></template>
Nueva Noticia
</a-button>
</div>
<!-- Filtros -->
<div class="filters-section">
<a-input-search
v-model:value="searchText"
placeholder="Buscar por título o descripción..."
@search="handleSearch"
style="width: 340px"
size="large"
allowClear
/>
<a-select
v-model:value="publicadoFilter"
placeholder="Publicado"
style="width: 200px"
size="large"
@change="handleFilterChange"
allowClear
>
<a-select-option :value="null">Todos</a-select-option>
<a-select-option :value="true">Publicado</a-select-option>
<a-select-option :value="false">Borrador</a-select-option>
</a-select>
<a-input
v-model:value="categoriaFilter"
placeholder="Categoría (opcional)"
style="width: 220px"
size="large"
allowClear
@pressEnter="handleFilterChange"
/>
<a-button size="large" @click="clearFilters">
<ReloadOutlined /> Limpiar
</a-button>
</div>
<!-- Tabla -->
<div class="areas-table-container">
<a-table
:data-source="noticiasStore.noticias"
:columns="columns"
:loading="noticiasStore.loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
class="areas-table"
>
<template #bodyCell="{ column, record }">
<!-- Imagen -->
<template v-if="column.key === 'imagen'">
<div class="thumb">
<a-image
v-if="record.imagen_url"
:src="record.imagen_url"
:preview="true"
:width="56"
:height="40"
style="object-fit: cover; border-radius: 8px"
/>
<div v-else class="thumb-empty">Sin imagen</div>
</div>
</template>
<!-- Publicado -->
<template v-if="column.key === 'publicado'">
<a-tag :color="record.publicado ? 'green' : 'default'">
{{ record.publicado ? "Publicado" : "Borrador" }}
</a-tag>
</template>
<!-- Destacado -->
<template v-if="column.key === 'destacado'">
<a-tag :color="record.destacado ? 'gold' : 'default'">
{{ record.destacado ? "Destacado" : "Normal" }}
</a-tag>
</template>
<!-- Fecha -->
<template v-if="column.key === 'fecha_publicacion'">
{{ formatDate(record.fecha_publicacion) }}
</template>
<!-- Acciones -->
<template v-if="column.key === 'acciones'">
<a-space>
<a-button type="link" class="action-btn" @click="showEditModal(record)">
<EditOutlined /> Editar
</a-button>
<a-button danger type="link" class="action-btn" @click="confirmDelete(record)">
<DeleteOutlined /> Eliminar
</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- Modal Crear / Editar -->
<a-modal
v-model:open="modalVisible"
:title="isEditing ? 'Editar Noticia' : 'Nueva Noticia'"
:confirm-loading="noticiasStore.saving"
@ok="handleSubmit"
@cancel="closeModal"
width="720px"
class="area-modal"
>
<a-form ref="formRef" :model="formState" layout="vertical">
<a-form-item label="Título" required>
<a-input v-model:value="formState.titulo" placeholder="Ej: Comunicado oficial..." />
</a-form-item>
<a-form-item label="Descripción corta (para tarjetas)" required>
<a-textarea
v-model:value="formState.descripcion_corta"
:rows="3"
placeholder="Resumen breve (máx ~500 caracteres)"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="Categoría (opcional)">
<a-input v-model:value="formState.categoria" placeholder="Ej: Resultados" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="Color del ribbon/tag (opcional)">
<a-input v-model:value="formState.tag_color" placeholder="blue | red | green | gold ..." />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="Fecha de publicación (opcional)">
<a-date-picker
v-model:value="fechaPicker"
style="width: 100%"
format="DD/MM/YYYY"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="Orden (opcional)">
<a-input-number v-model:value="formState.orden" style="width: 100%" :min="0" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :xs="24" :md="12">
<a-form-item label="Publicado">
<a-switch v-model:checked="formState.publicado" checked-children="Sí" un-checked-children="No" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="Destacado">
<a-switch v-model:checked="formState.destacado" checked-children="Sí" un-checked-children="No" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="Contenido (opcional)">
<a-textarea
v-model:value="formState.contenido"
:rows="6"
placeholder="Contenido completo (si lo usarás en el detalle de la noticia)"
/>
</a-form-item>
<a-row :gutter="16">
<a-col :xs="24" :md="16">
<a-form-item label="Link (opcional)">
<a-input v-model:value="formState.link_url" placeholder="https://..." />
</a-form-item>
</a-col>
<a-col :xs="24" :md="8">
<a-form-item label="Texto del link (opcional)">
<a-input v-model:value="formState.link_texto" placeholder="Ver más" />
</a-form-item>
</a-col>
</a-row>
<!-- Imagen -->
<a-form-item label="Imagen (opcional)">
<a-upload
:before-upload="beforeUpload"
:max-count="1"
:show-upload-list="false"
>
<a-button>
<UploadOutlined /> Seleccionar imagen
</a-button>
</a-upload>
<div class="image-preview" v-if="imagePreviewUrl || formState.imagen_url">
<a-image
:src="imagePreviewUrl || formState.imagen_url"
:preview="true"
style="border-radius: 12px; overflow: hidden"
/>
<a-button danger type="link" @click="clearImage" class="remove-image">
Quitar imagen
</a-button>
</div>
</a-form-item>
<a-alert
v-if="noticiasStore.error"
type="error"
show-icon
:message="noticiasStore.error"
/>
</a-form>
</a-modal>
<!-- Modal Eliminar -->
<a-modal
v-model:open="deleteModalVisible"
title="Eliminar Noticia"
ok-type="danger"
ok-text="Eliminar"
:confirm-loading="noticiasStore.deleting"
@ok="handleDelete"
@cancel="deleteModalVisible = false"
width="520px"
>
<a-alert type="warning" show-icon message="¿Deseas eliminar esta noticia?" />
<div class="delete-info">
<p><strong>{{ noticiaToDelete?.titulo }}</strong></p>
<p class="muted">Esta acción no se puede deshacer.</p>
</div>
</a-modal>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from "vue"
import { message } from "ant-design-vue"
import dayjs from "dayjs"
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
ReloadOutlined,
UploadOutlined,
} from "@ant-design/icons-vue"
import { useNoticiasStore } from "../../../store/noticiasStore"
const noticiasStore = useNoticiasStore()
const modalVisible = ref(false)
const deleteModalVisible = ref(false)
const isEditing = ref(false)
const noticiaToDelete = ref(null)
const formRef = ref()
const searchText = ref("")
const publicadoFilter = ref(null)
const categoriaFilter = ref("")
const fechaPicker = ref(null) // dayjs
const imagePreviewUrl = ref("")
const selectedImageFile = ref(null)
const formState = reactive({
id: null,
titulo: "",
descripcion_corta: "",
contenido: "",
categoria: "",
tag_color: "",
link_url: "",
link_texto: "",
fecha_publicacion: null, // ISO string (opcional)
publicado: false,
destacado: false,
orden: null,
// solo para mostrar cuando editas
imagen_url: null,
})
const pagination = computed(() => ({
current: noticiasStore.meta?.current_page || 1,
pageSize: noticiasStore.meta?.per_page || 9,
total: noticiasStore.meta?.total || 0,
showSizeChanger: true,
}))
const columns = [
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
{ title: "Imagen", key: "imagen", width: 90 },
{ title: "Título", dataIndex: "titulo", key: "titulo" },
{ title: "Categoría", dataIndex: "categoria", key: "categoria", width: 140 },
{ title: "Publicado", dataIndex: "publicado", key: "publicado", width: 110 },
{ title: "Destacado", dataIndex: "destacado", key: "destacado", width: 120 },
{ title: "Fecha", dataIndex: "fecha_publicacion", key: "fecha_publicacion", width: 140 },
{ title: "Acciones", key: "acciones", width: 180, align: "center" },
]
const showCreateModal = () => {
isEditing.value = false
resetForm()
modalVisible.value = true
}
const showEditModal = (noticia) => {
isEditing.value = true
resetForm()
Object.assign(formState, {
id: noticia.id,
titulo: noticia.titulo || "",
descripcion_corta: noticia.descripcion_corta || "",
contenido: noticia.contenido || "",
categoria: noticia.categoria || "",
tag_color: noticia.tag_color || "",
link_url: noticia.link_url || "",
link_texto: noticia.link_texto || "",
fecha_publicacion: noticia.fecha_publicacion || null,
publicado: !!noticia.publicado,
destacado: !!noticia.destacado,
orden: noticia.orden ?? null,
imagen_url: noticia.imagen_url || null,
})
fechaPicker.value = noticia.fecha_publicacion ? dayjs(noticia.fecha_publicacion) : null
modalVisible.value = true
}
const closeModal = () => {
modalVisible.value = false
resetForm()
}
const resetForm = () => {
Object.assign(formState, {
id: null,
titulo: "",
descripcion_corta: "",
contenido: "",
categoria: "",
tag_color: "",
link_url: "",
link_texto: "",
fecha_publicacion: null,
publicado: false,
destacado: false,
orden: null,
imagen_url: null,
})
fechaPicker.value = null
imagePreviewUrl.value = ""
selectedImageFile.value = null
}
watch(fechaPicker, (v) => {
formState.fecha_publicacion = v ? v.toISOString() : null
})
const beforeUpload = (file) => {
// preview local
selectedImageFile.value = file
imagePreviewUrl.value = URL.createObjectURL(file)
message.success("Imagen seleccionada")
// IMPORTANTE: evitar auto-upload (lo hacemos con el submit)
return false
}
const clearImage = () => {
selectedImageFile.value = null
imagePreviewUrl.value = ""
// si quieres que al actualizar se borre imagen, envía imagen_path = null
// aquí solo la quito del preview; el back no borra a menos que lo programes.
}
const handleSubmit = async () => {
try {
if (!formState.titulo?.trim() || !formState.descripcion_corta?.trim()) {
message.warning("Completa al menos Título y Descripción corta.")
return
}
const payload = {
titulo: formState.titulo,
descripcion_corta: formState.descripcion_corta,
contenido: formState.contenido || null,
categoria: formState.categoria || null,
tag_color: formState.tag_color || null,
link_url: formState.link_url || null,
link_texto: formState.link_texto || null,
fecha_publicacion: formState.fecha_publicacion || null,
publicado: formState.publicado,
destacado: formState.destacado,
orden: formState.orden,
}
if (selectedImageFile.value) payload.imagen = selectedImageFile.value
if (isEditing.value) {
await noticiasStore.actualizarNoticia(formState.id, payload)
message.success("Noticia actualizada")
} else {
await noticiasStore.crearNoticia(payload)
message.success("Noticia creada")
}
closeModal()
await fetchTable()
} catch (e) {
message.error("Error al guardar")
}
}
const confirmDelete = (noticia) => {
noticiaToDelete.value = noticia
deleteModalVisible.value = true
}
const handleDelete = async () => {
try {
await noticiasStore.eliminarNoticia(noticiaToDelete.value.id)
message.success("Noticia eliminada")
deleteModalVisible.value = false
await fetchTable()
} catch {
message.error("Error al eliminar")
}
}
const handleSearch = async () => {
noticiasStore.setFiltro("q", searchText.value)
noticiasStore.setFiltro("page", 1)
await fetchTable()
}
const handleFilterChange = async () => {
noticiasStore.setFiltro("publicado", publicadoFilter.value)
noticiasStore.setFiltro("categoria", categoriaFilter.value)
noticiasStore.setFiltro("page", 1)
await fetchTable()
}
const clearFilters = async () => {
searchText.value = ""
publicadoFilter.value = null
categoriaFilter.value = ""
noticiasStore.resetFiltros()
await fetchTable()
}
const handleTableChange = async (pg) => {
noticiasStore.setFiltro("page", pg.current)
noticiasStore.setFiltro("per_page", pg.pageSize)
await fetchTable()
}
const fetchTable = async () => {
await noticiasStore.cargarNoticias()
}
const formatDate = (date) => {
if (!date) return "-"
const d = new Date(date)
if (isNaN(d.getTime())) return "-"
return d.toLocaleDateString("es-PE")
}
onMounted(async () => {
await fetchTable()
})
</script>
<style scoped>
.areas-container {
padding: 0;
}
.areas-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding: 0 8px;
}
.header-title h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #1f1f1f;
}
.subtitle {
margin: 4px 0 0 0;
color: #666;
font-size: 14px;
}
.filters-section {
display: flex;
align-items: center;
margin-bottom: 24px;
padding: 0 8px;
flex-wrap: wrap;
gap: 12px;
}
.areas-table-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border: 1px solid #f0f0f0;
overflow: hidden;
}
.areas-table :deep(.ant-table-thead > tr > th) {
background: #fafafa;
font-weight: 600;
color: #1f1f1f;
border-bottom: 1px solid #f0f0f0;
}
.areas-table :deep(.ant-table-tbody > tr > td) {
border-bottom: 1px solid #f0f0f0;
}
.areas-table :deep(.ant-table-tbody > tr:hover > td) {
background: #fafafa;
}
.action-btn {
padding: 4px 8px;
height: auto;
}
.thumb {
display: flex;
align-items: center;
justify-content: center;
}
.thumb-empty {
width: 56px;
height: 40px;
border-radius: 8px;
border: 1px dashed #d9d9d9;
display: grid;
place-items: center;
font-size: 12px;
color: #999;
}
.image-preview {
margin-top: 10px;
border: 1px solid #f0f0f0;
border-radius: 14px;
padding: 10px;
background: #fafafa;
}
.remove-image {
padding: 0;
margin-top: 6px;
}
.delete-info {
margin-top: 12px;
}
.muted {
color: #777;
margin: 0;
}
/* Responsive */
@media (max-width: 768px) {
.areas-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.filters-section {
flex-direction: column;
align-items: stretch;
}
.filters-section .ant-input-search,
.filters-section .ant-select,
.filters-section .ant-input,
.filters-section .ant-btn {
width: 100% !important;
}
.areas-table-container {
overflow-x: auto;
}
.areas-table {
min-width: 980px;
}
}
</style>

File diff suppressed because it is too large Load Diff

@ -4,10 +4,10 @@
<a-col :xs="22" :sm="20" :md="20" :lg="16" :xl="14">
<div class="auth-shell">
<a-row :gutter="[0, 0]" class="auth-layout">
<!-- PANEL IZQUIERDO: FORM -->
<a-col :xs="24" :md="12" class="auth-pane auth-pane-form">
<div class="pane-inner">
<!-- Branding -->
<div class="brand">
<div class="brand-mark">
<img
@ -56,7 +56,6 @@
</a-input>
</a-form-item>
<!-- Nombre -->
<a-form-item v-if="isRegister" label="Nombre completo" name="name">
<a-input
v-model:value="formState.name"
@ -67,14 +66,14 @@
</a-input>
</a-form-item>
<!-- Email -->
<a-form-item label="Correo electrónico" name="email">
<a-input v-model:value="formState.email" size="large" placeholder="correo@ejemplo.com">
<template #prefix><MailOutlined /></template>
</a-input>
</a-form-item>
<!-- Password -->
<a-form-item label="Contraseña" name="password">
<a-input-password
v-model:value="formState.password"
@ -85,7 +84,7 @@
</a-input-password>
</a-form-item>
<!-- Confirm Password -->
<a-form-item
v-if="isRegister"
label="Confirmar contraseña"
@ -100,7 +99,7 @@
</a-input-password>
</a-form-item>
<!-- Recordarme -->
<a-row
v-if="!isRegister"
justify="space-between"
@ -138,7 +137,6 @@
</div>
</a-col>
<!-- PANEL DERECHO: INFO (CORTO Y DINÁMICO) -->
<a-col :xs="24" :md="12" class="auth-pane auth-pane-info">
<div class="pane-inner pane-inner-info">
<div class="info-top">
@ -151,8 +149,8 @@
<a-typography-text type="secondary">
{{
isRegister
? "Crea tu cuenta para iniciar tu inscripción al proceso de admisión."
: "Inicia sesión para continuar tu inscripción y consultar tu estado."
? "Crea tu cuenta para participar en el proceso de admisión y acceder a todos los servicios del portal."
: "Ingresa al portal para gestionar tu inscripción, revisar procesos disponibles y rendir un test de referencia."
}}
</a-typography-text>
</div>
@ -163,35 +161,44 @@
</div>
<div class="info-list">
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Completar tu postulación</b> (modalidad, sede y programa).</span>
<span><b>Rendir un test de referencia</b>.</span>
</div>
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Subir requisitos</b> y revisar observaciones.</span>
<span><b>Ver procesos disponibles</b> según tu modalidad.</span>
</div>
<div class="info-item" v-if="!isRegister">
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Ver comunicados y resultados</b> del proceso.</span>
<span><b>Consultar tu estado</b> de inscripción y seguimiento del proceso.</span>
</div>
<!-- <div class="info-item">
<span class="info-bullet"></span>
<span><b>Ver tu resultado detallado</b> por cursos.</span>
</div> -->
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Consultar tu estado</b> de inscripción/admisión.</span>
<span><b>Revisar comunicados oficiales</b> del proceso de admisión.</span>
</div>
</div>
</div>
<div class="info-foot">
<a-typography-text type="secondary">
Soporte: Mesa de ayuda Atención en horario institucional
Plataforma oficial de admisión Soporte en horario institucional
</a-typography-text>
</div>
</div>
</a-col>
</a-row>
</div>
</a-col>
@ -214,10 +221,7 @@ const isRegister = ref(false);
const rememberMe = ref(false);
const loading = ref(false);
/**
* Logo en /public (recomendado):
* public/logotiny.png -> "/logotiny.png"
*/
const logoSrc = "/logotiny.png";
const logoError = ref(false);
@ -470,7 +474,6 @@ checkExistingAuth();
font-weight: 800;
}
/* Info panel corto */
.info-top {
text-align: left;
}
@ -522,7 +525,6 @@ checkExistingAuth();
border-top: 1px solid var(--ant-colorBorderSecondary, rgba(0, 0, 0, 0.06));
}
/* Responsive */
@media (max-width: 768px) {
.auth-pane {
padding: 22px;
@ -536,7 +538,6 @@ checkExistingAuth();
}
}
/* Fallback si no hay color-mix */
@supports not (color: color-mix(in srgb, white 50%, black)) {
.info-tag {
background: rgba(22, 119, 255, 0.12);

@ -1,36 +1,44 @@
<template>
<a-card class="procesos-card" :bordered="false">
<div class="title">Mis procesos de admisión</div>
<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="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"
type="primary"
>
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>
<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>
<div class="tableWrap desktopOnly">
<a-table
class="procesos-table"
:dataSource="procesosFiltrados"
:columns="columns"
rowKey="id"
@ -38,170 +46,209 @@
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<!-- Nombre -->
<template v-if="column.key === 'nombre'">
<div class="nombre">
{{ record.nombre || '-' }}
</div>
<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>
<div class="puntaje">{{ record.puntaje ?? "-" }}</div>
<div class="meta">Puntaje</div>
</template>
<!-- Apto -->
<template v-else-if="column.key === 'apto'">
<a-tag :color="aptoColor(record.apto)" class="tag-pill">
<span class="statusPill" :class="statusClass(record.apto)">
{{ aptoTexto(record.apto) }}
</a-tag>
</span>
</template>
<!-- Acciones -->
<template v-else-if="column.key === 'acciones'">
<a-space>
<a-button size="small" @click="verDetalle(record)">Ver detalle</a-button>
</a-space>
<a-button size="small" @click="verDetalle(record)">
Ver detalle
</a-button>
</template>
</template>
<template #emptyText>
<a-empty description="No se encontraron procesos" />
</template>
</a-table>
</div>
<div class="cards mobileOnly">
<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>
<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"
block
class="btnPrimary"
@click="verDetalle(p)"
>
Ver detalle
</a-button>
</div>
</div>
</template>
<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 } 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')
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) return "APTO";
if (apto == 0) return "NO APTO";
return "-";
};
const aptoColor = (apto) => {
if (apto === 1 || apto === true || apto === '1') return 'green'
if (apto === 0 || apto === false || apto === '0') return 'red'
return 'default'
}
const statusClass = (apto) => {
if (apto == 1) return "ok";
if (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 ?? "-"}`);
};
onMounted(() => {
obtenerProcesos()
})
obtenerProcesos();
});
</script>
<style scoped>
.procesos-card {
.card {
width: 100%;
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 {
display: flex;
justify-content: space-between;
gap: 12px;
gap: 16px;
flex-wrap: wrap;
align-items: flex-start;
}
.title-left {
display: flex;
flex-direction: column;
gap: 4px;
.title {
font-size: clamp(1.2rem, 4vw, 1.8rem);
font-weight: 700;
color: #0d1b52;
line-height: 1.2;
word-break: break-word;
}
.title-main {
font-weight: 900;
font-size: 18px;
color: #111827;
.subtitle {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.title-sub {
font-weight: 650;
color: #6b7280;
font-size: 13px;
.tools {
display: grid;
grid-template-columns: 220px 1fr;
gap: 12px;
margin-bottom: 12px;
}
.top-summary {
.counter {
background: #fafafa;
border-radius: 12px;
padding: 10px 12px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 14px;
font-weight: 700;
}
.summary-alert {
border-radius: 14px;
margin: 0;
flex: 1;
min-width: 280px;
.counterValue {
font-size: 18px;
}
.search-input {
max-width: 360px;
.search {
border-radius: 12px;
}
.tableWrap {
width: 100%;
overflow-x: auto;
}
.nombre {
font-weight: 850;
color: #111827;
font-weight: 800;
}
.puntaje {
@ -209,14 +256,105 @@ onMounted(() => {
color: #1677ff;
}
.tag-pill {
.meta {
font-size: 12px;
color: #6b7280;
}
.statusPill {
padding: 4px 10px;
border-radius: 999px;
font-weight: 800;
padding: 2px 10px;
font-size: 12px;
font-weight: 900;
background: #f5f5f5;
}
.statusPill.ok {
background: rgba(22,119,255,.1);
border: 1px solid rgba(22,119,255,.3);
}
.statusPill.bad {
background: rgba(0,0,0,.04);
}
.cards {
display: grid;
gap: 12px;
}
.procesos-table :deep(.ant-table) {
.itemCard {
border: 1px solid rgba(0,0,0,.08);
border-radius: 14px;
overflow: hidden;
padding: 12px;
}
.itemTop {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
}
.itemTitle {
font-weight: 900;
}
.itemGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 10px;
margin-top: 10px;
}
.kv {
background: #fafafa;
border-radius: 12px;
padding: 10px;
}
.k {
font-size: 12px;
color: #6b7280;
}
.v {
font-weight: 900;
}
.v.strong {
color: #1677ff;
}
.btnPrimary {
height: 42px;
border-radius: 12px;
font-weight: 900;
margin-top: 12px;
}
.desktopOnly { display: block; }
.mobileOnly { display: none; }
@media (max-width: 768px) {
.tools {
grid-template-columns: 1fr;
}
.desktopOnly { display: none; }
.mobileOnly { display: block; }
.header {
flex-direction: column;
}
.title {
font-size: 1.3rem;
}
.card {
margin: 0;
border-radius: 0;
}
}
</style>

@ -1,39 +1,39 @@
<template>
<a-card class="pagos-card" :bordered="false">
<div class="title">Mis pagos realizados</div>
<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="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>
<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 -->
<div v-if="!isMobile" class="tableWrap">
<a-table
class="pagos-table"
class="table"
:dataSource="pagosFiltrados"
:columns="columns"
rowKey="key"
@ -41,162 +41,148 @@
: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>
<span class="pill pill--neutral">{{ tipoLabel(record.tipo) }}</span>
</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 class="codigoCell">
<div class="codigo">{{ record.codigo || "-" }}</div>
<span v-if="record.estado" class="pill pill--soft">{{ record.estado }}</span>
</div>
</template>
<!-- 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">
<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>
<!-- Monto -->
<template v-else-if="column.key === 'monto'">
<span class="monto">
S/ {{ Number(record.monto || 0).toFixed(2) }}
</span>
<div class="monto">S/ {{ Number(record.monto || 0).toFixed(2) }}</div>
</template>
<!-- Fecha -->
<template v-else-if="column.key === 'fecha_pago'">
<span class="fecha">{{ formatFecha(record.fecha_pago) }}</span>
<div class="fecha">{{ formatFecha(record.fecha_pago) }}</div>
</template>
</template>
<!-- Empty -->
<template #emptyText>
<a-empty description="No se encontraron pagos" />
</template>
</a-table>
</div>
<!-- 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."
/>
<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>
<div class="grid">
<div class="kv">
<div class="k">Código</div>
<div class="v strong">{{ p.codigo || "-" }}</div>
</div>
<div class="kv">
<div class="k">Monto</div>
<div class="v strong primary">S/ {{ Number(p.monto || 0).toFixed(2) }}</div>
</div>
<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-empty v-else description="No se encontraron pagos" />
</div>
<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 +191,278 @@ 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()
.join(" ")
.toLowerCase();
return texto.includes(q);
});
});
const isMobile = ref(false);
let mq = null;
return texto.includes(q)
})
})
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 {
.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 {
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 {
font-weight: 900;
font-size: 18px;
color: #111827;
.title {
font-size: clamp(1.2rem, 4vw, 1.8rem);
font-weight: 700;
color: #0d1b52;
line-height: 1.2;
word-break: break-word;
}
.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 {
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;
.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 {
.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);
color: var(--ant-colorTextHeading, #111827);
}
.codigo-cell {
.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);
}
.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;
.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 {
.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;
}
@media (max-width: 640px) {
.card {
margin: 0;
border-radius: 0;
}
.headerRight {
width: 100%;
min-width: 0;
}
.title {
font-size: 1.3rem;
}
.tools {
grid-template-columns: 1fr;
}
.grid {
grid-template-columns: 1fr;
}
}
</style>

@ -1,4 +1,3 @@
<!-- views/PortalView.vue -->
<template>
<a-layout class="portal-layout">
<!-- Header -->
@ -19,7 +18,7 @@
</div>
<div class="header-right">
<!-- Perfil -->
<a-dropdown :trigger="['click']" placement="bottomRight">
<div class="profile-trigger">
<a-avatar
@ -55,13 +54,6 @@
</div>
</div>
<div class="dropdown-item" @click="goToProfile">
<UserOutlined class="dropdown-icon" />
<div class="dropdown-item-content">
<div class="dropdown-item-title">Mi Perfil</div>
<div class="dropdown-item-subtitle">Información personal</div>
</div>
</div>
<div class="dropdown-divider" />
@ -79,19 +71,17 @@
</div>
</a-layout-header>
<!-- Main layout -->
<a-layout
class="main-layout"
:class="{ 'layout-collapsed': sidebarCollapsed && !isMobile }"
>
<!-- Backdrop móvil -->
<div
v-if="isMobile && !sidebarCollapsed"
class="sidebar-backdrop"
@click="sidebarCollapsed = true"
/>
<!-- Sidebar -->
<a-layout-sider
v-model:collapsed="sidebarCollapsed"
:width="sidebarWidth"
@ -186,7 +176,6 @@
</div>
</a-layout-sider>
<!-- Content -->
<a-layout-content class="content">
<div class="content-container">
<a-card class="content-card" :bordered="false">
@ -260,7 +249,6 @@ const getAvatarColor = (name) => {
const handleMenuSelect = ({ key }) => {
selectedKeys.value = [key]
// Ajusta aquí tus rutas reales
const routes = {
'dashboard-postulante': { name: 'DashboardPostulante' },
'test-postulante': { name: 'TestPostulante' },
@ -280,16 +268,12 @@ const handleLogout = async () => {
try {
await authStore.logout()
message.success('Sesión cerrada correctamente')
router.push('/login-postulante')
router.push('/')
} catch (error) {
message.error('Error al cerrar sesión')
}
}
const goToProfile = () => {
message.info('Perfil del postulante')
// router.push('/portal/perfil')
}
const openHelp = () => {
message.info('Centro de ayuda disponible')
@ -305,20 +289,20 @@ onUnmounted(() => {
})
</script>
<style scoped>
/* Tipografía institucional */
.portal-layout,
.portal-layout * {
font-family: "Times New Roman", Times, serif;
}
/* Layout base */
.portal-layout {
min-height: 100vh;
background: var(--ant-colorBgLayout, #f5f5f5);
}
/* ===== Header ===== */
.header {
height: 64px;
padding: 0;
@ -335,8 +319,8 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32px; /* más aire */
width: 100%; /* ocupa todo */
padding: 0 32px;
width: 100%;
}
.header-left {
@ -391,7 +375,6 @@ onUnmounted(() => {
margin-top: 2px;
}
/* Profile trigger */
.header-right {
display: flex;
align-items: center;
@ -420,8 +403,8 @@ onUnmounted(() => {
.profile-text {
display: flex;
flex-direction: column;
gap: 0; /* ✅ elimina separación */
line-height: 1.05; /* ✅ compacto */
gap: 0;
line-height: 1.05;
}
@ -429,7 +412,7 @@ onUnmounted(() => {
font-size: 13px;
font-weight: 800;
color: var(--ant-colorText, #374151);
margin: 0; /* ✅ sin margen */
margin: 0;
padding: 0;
}
@ -438,9 +421,9 @@ onUnmounted(() => {
font-size: 12px;
font-weight: 700;
color: var(--ant-colorTextSecondary, #6b7280);
margin: 0; /* ✅ sin margen */
margin: 0;
padding: 0;
transform: translateY(-1px); /* ✅ sube 1px (opcional) */
transform: translateY(-1px);
}
.dropdown-chevron {
font-size: 12px;
@ -522,7 +505,6 @@ onUnmounted(() => {
color: #ff4d4f;
}
/* ===== Sidebar ===== */
.sidebar {
background: var(--ant-colorBgContainer, #fff);
border-right: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
@ -564,7 +546,6 @@ onUnmounted(() => {
letter-spacing: .5px;
}
/* ✅ AntDV menu selected/hover correctos */
.sidebar-menu :deep(.ant-menu-item) {
border-radius: 12px;
margin: 4px 6px;
@ -592,7 +573,6 @@ onUnmounted(() => {
margin-left: auto;
}
/* Sidebar Footer */
.sidebar-footer {
position: absolute;
bottom: 0;
@ -638,7 +618,6 @@ onUnmounted(() => {
margin-top: 2px;
}
/* ===== Main + Content ===== */
.main-layout {
margin-left: 280px;
transition: margin-left .25s ease;
@ -665,10 +644,36 @@ onUnmounted(() => {
border-radius: 16px;
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
box-shadow: var(--ant-boxShadowSecondary, 0 10px 28px rgba(0,0,0,.08));
background: var(--ant-colorBgContainer, #fff);
background: #fbfcff;
}
.content-card::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
repeating-linear-gradient(
to right,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 24px
),
repeating-linear-gradient(
to bottom,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 24px
);
opacity: 0.55;
}
/* ===== Mobile ===== */
.sidebar-backdrop {
position: fixed;
inset: 0;
@ -676,18 +681,15 @@ onUnmounted(() => {
z-index: 998;
}
/* Sidebar en móvil */
.sidebar.sidebar-mobile {
top: 64px;
height: calc(100vh - 64px);
}
/* AntDV collapsed mobile (width 0) ya lo oculta, pero reforzamos por UX */
.sidebar.sidebar-mobile :deep(.ant-layout-sider-children) {
height: 100%;
}
/* Responsive */
@media (max-width: 992px) {
.main-layout,
.main-layout.layout-collapsed {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -3,6 +3,9 @@ import { ref, computed, reactive, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { useExamenStore } from "../../store/examen.store";
import { message } from "ant-design-vue";
import voucherCaja from "../../assets/images/caja.png";
import voucherPagalo from "../../assets/images/pagalo.png";
import voucherBn from "../../assets/images/boletabn.jpg";
const router = useRouter();
const examenStore = useExamenStore();
@ -10,7 +13,12 @@ const examenStore = useExamenStore();
const showModal = ref(false);
const iniciandoExamen = ref(false);
const creandoExamen = ref(false);
const formRef = ref();
const formRef = ref(null);
const loadingPage = ref(false);
const loadingAreas = ref(false);
const formState = reactive({
proceso_id: undefined,
@ -19,7 +27,7 @@ const formState = reactive({
codigo_pago: "",
});
/* ===== Modal Voucher ===== */
const secuenciaModalOpen = ref(false);
const secuenciaTipo = ref("caja");
@ -33,8 +41,13 @@ const secuenciaTitle = computed(() => {
});
const voucherSrc = computed(() => {
const map = { caja: "/voucher-caja.png", pagalo: "/voucher-pagalo.png", bn: "/voucher-bn.png" };
return map[secuenciaTipo.value] || "/voucher-bn.png";
const map = {
caja: voucherCaja,
pagalo: voucherPagalo,
bn: voucherBn,
};
return map[secuenciaTipo.value] || "";
});
const habilitacionTexto = computed(() =>
@ -50,28 +63,20 @@ const openSecuencia = (tipo) => {
const modalBodyStyle = computed(() => ({ maxHeight: "72vh", overflowY: "auto" }));
/* ===== Datos ===== */
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 ===== */
const stepCurrent = computed(() => {
if (!hasExamen.value) return 0;
return yaDioTest.value ? 2 : 1;
@ -94,24 +99,25 @@ const estadoAlertDesc = computed(() => {
: "Son 10 preguntas. Al finalizar verás tu resultado al instante.";
});
/* ===== CTA único (sin duplicar botones) ===== */
const primaryAction = computed(() => {
if (!hasExamen.value) {
return { label: "Seleccionar área", type: "primary", loading: false, onClick: () => (showModal.value = true) };
}
if (!yaDioTest.value) {
return { label: "Iniciar test", type: "primary", loading: iniciandoExamen.value, onClick: irAlExamen };
}
return { label: "Ver resultados", type: "primary", loading: false, onClick: verResultado };
});
const canIniciar = computed(() => hasExamen.value && !yaDioTest.value);
const canVerResultados = computed(() => hasExamen.value && yaDioTest.value);
const openSeleccionArea = () => {
showModal.value = true;
};
/* ===== 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 +126,22 @@ const tipoPagoOptions = [
{ value: "caja", label: "Caja" },
];
const normalizeRequierePago = (v) => v === 1 || v === "1" || v === true || v === "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;
return normalizeRequierePago(proceso.requiere_pago);
});
/* ===== Validación ===== */
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 +167,37 @@ const rules = {
],
};
/* ===== Actions ===== */
const refrescar = async () => {
try {
await examenStore.fetchExamenActual();
message.success("Actualizado");
} catch {
message.error("No se pudo actualizar");
}
};
const handleProcesoChange = async (procesoId) => {
formState.area_proceso_id = undefined;
if (procesoId) await examenStore.fetchAreas(procesoId);
formState.tipo_pago = undefined;
formState.codigo_pago = "";
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,6 +208,7 @@ const crearExamen = async () => {
try {
creandoExamen.value = true;
if (formRef.value) await formRef.value.validate();
const pagoData = procesoRequierePago.value
@ -178,13 +217,16 @@ const crearExamen = async () => {
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");
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,128 +279,161 @@ 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 ===== */
watch(showModal, (open) => {
if (!open) resetModal();
});
onMounted(async () => {
loadingPage.value = true;
try {
await examenStore.fetchProcesos();
await examenStore.fetchExamenActual();
});
watch(showModal, (newVal) => {
if (!newVal) {
examenStore.areas = [];
resetModal();
} 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">
<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="heroKicker">Test diagnóstico</div>
<div class="heroTitle">Practica y conoce tu nivel</div>
<div class="heroText">10 preguntas 10 min aprox. Resultado inmediato</div>
<section class="hero">
<div class="heroLeft">
<a-space direction="vertical" size="middle" class="mt16" style="width: 100%">
<a-card size="small">
<a-space align="start">
<div>
<div style="font-weight: 700; color:#1a237e;">Test referencial (no afecta tu postulación)</div>
<div class="muted">
Este test te ayuda a medir tu nivel y practicar. No cambia tu puntaje ni tu postulación oficial.
</div>
<div class="header__subtitle">
Es una guía para que midas tu nivel. <b>No afecta</b> tu admisión.
</div>
</a-space>
</a-card>
</a-space>
<div class="heroFacts hide-mobile">
<div class="fact">
<div class="factK">Nombre:</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>
<div class="header__right">
<a-button @click="refrescar">Actualizar</a-button>
<div class="bankActions">
<div class="bankHead">
<div class="bankTitle">Requisito de acceso</div>
<div class="bankSub">
Este test es completamente GRATUITO.
No se realiza ningún pago adicional.
Únicamente debes ingresar el número de secuencia del pago de tu Carpeta de Postulante, el cual ya realizaste previamente para el proceso de admisión.
</div>
</div>
<div style="font-weight: 700; color:#1a237e;">Ver Secuencia</div>
<a-space wrap>
<a-button class="bankBtn" @click="openSecuencia('caja')">Caja</a-button>
<a-button class="bankBtn" @click="openSecuencia('pagalo')">pagalo.pe</a-button>
<a-button class="bankBtn" @click="openSecuencia('bn')">Banco Nación</a-button>
</a-space>
</div>
</div>
</template>
<!-- 1 bloque principal (sin cards anidados) -->
<div class="content">
<!-- Mensaje principal -->
<a-alert :type="estadoAlertType" show-icon :message="estadoAlertMessage" :description="estadoAlertDesc" />
<div class="heroRight">
<div class="ctaCard">
<div class="ctaHeader">
<div>
<div class="ctaTitle">Acción rápida</div>
<div class="ctaHint">
<span v-if="!hasExamen">Elige tu área y genera tu test.</span>
<span v-else-if="hasExamen && !yaDioTest">Cuando estés listo, empieza.</span>
<span v-else>Revisa tu resultado cuando quieras.</span>
</div>
</div>
<a-divider />
<div class="statusTag" :class="{ ok: yaDioTest, pending: hasExamen && !yaDioTest, empty: !hasExamen }">
{{ estadoTexto }}
</div>
</div>
<a-button type="default" size="large" @click="openSeleccionArea" class="ctaBtn ghostBtn" block>
Seleccionar área
</a-button>
<!-- 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"
v-if="canIniciar"
type="primary"
size="large"
:loading="primaryAction.loading"
@click="primaryAction.onClick"
class="ctaBtn"
:loading="iniciandoExamen"
@click="irAlExamen"
class="ctaBtn primaryBtn"
block
>
{{ primaryAction.label }}
Iniciar test
</a-button>
<!-- Link discreto (solo si aplica) -->
<a-button v-if="procesoRequierePago" type="link" @click="openSecuencia('bn')">
¿Dónde veo mi secuencia?
<a-button
v-if="canVerResultados"
type="primary"
size="large"
@click="verResultado"
class="ctaBtn primaryBtn"
block
>
Ver resultados
</a-button>
<a-divider class="ctaDivider" />
<a-alert
class="mt12"
:type="estadoAlertType"
show-icon
:message="estadoAlertMessage"
:description="estadoAlertDesc"
/>
</div>
</div>
</section>
<a-divider />
<!-- Progreso -->
<div class="subhead">Tu camino</div>
<a-steps :current="stepCurrent" size="small">
<section class="section">
<div class="sectionTitle">Tu camino</div>
<div class="sectionSub">Simple, rápido y sin estrés.</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">
<span v-if="!hasExamen">Tip: selecciona un área y luego podrás iniciar.</span>
<div class="hint mt12">
<span v-if="!hasExamen">Tip: elige tu área para generar el test.</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 />
<!-- FAQ corto -->
<a-collapse accordion>
<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>
<li>No cuenta como nota de admisión.</li>
<li>Al terminar ves tu resultado y recomendaciones.</li>
</ul>
</a-collapse-panel>
<a-collapse-panel v-if="procesoRequierePago" key="2" header="Secuencia de pago (si tu proceso lo requiere)">
<a-alert
type="info"
show-icon
message="El test es gratuito"
description="Solo se te pedirá la secuencia del pago de la Carpeta de Postulante (si tu proceso la requiere)."
class="mb12"
/>
<a-space wrap>
<a-button @click="openSecuencia('caja')">Caja</a-button>
<a-button @click="openSecuencia('pagalo')">pagalo.pe</a-button>
<a-button @click="openSecuencia('bn')">Banco Nación</a-button>
</a-space>
</a-collapse-panel>
</a-collapse>
</div>
<!-- ================== MODAL SECUENCIA ================== -->
<a-modal
v-model:open="secuenciaModalOpen"
:title="secuenciaTitle"
@ -372,7 +447,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 +460,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>
@ -406,13 +481,13 @@ watch(showModal, (newVal) => {
</a-row>
</a-modal>
<!-- ================== MODAL SELECCIÓN ÁREA ================== -->
<a-modal
v-model:open="showModal"
title="Seleccionar área"
:mask-closable="false"
width="640px"
@cancel="resetModal"
@cancel="showModal = false"
class="select-modal"
>
<a-alert
v-if="hasExamen"
@ -430,7 +505,7 @@ watch(showModal, (newVal) => {
placeholder="Selecciona un proceso"
:options="procesoOptions"
@change="handleProcesoChange"
:loading="examenStore.cargando"
:loading="loadingPage"
/>
</a-form-item>
@ -440,17 +515,23 @@ watch(showModal, (newVal) => {
placeholder="Selecciona un área"
:options="areaOptions"
:disabled="!formState.proceso_id"
:loading="examenStore.cargando"
:loading="loadingAreas"
/>
</a-form-item>
<div v-if="procesoRequierePago" class="pay-block">
<a-alert message="Este proceso requiere pago" type="info" show-icon class="mb12" />
<a-alert
message="Este proceso requiere secuencia"
type="info"
show-icon
class="mb12"
description="Puedes usar la secuencia del pago de tu Carpeta de Postulante (si ya la pagaste)."
/>
<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-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" />
<a-input v-model:value="formState.codigo_pago" placeholder="Escribe la secuencia / código" />
</a-form-item>
</div>
</a-form>
@ -464,102 +545,297 @@ watch(showModal, (newVal) => {
</a-space>
</template>
</a-modal>
</a-card>
</template>
<style scoped>
/* Sin degradados, formal, y agradable:
- Más aire
- Mensajes cortos
- 1 CTA principal
- Tipos legibles
*/
.page-card {
max-width: 1100px;
.test-modern {
position: relative;
padding: 40px 0;
font-family: "Times New Roman", Times, serif;
background: #fbfcff;
overflow: hidden;
}
.test-modern::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
repeating-linear-gradient(
to right,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 24px
),
repeating-linear-gradient(
to bottom,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 24px
);
opacity: 0.55;
}
.section-container {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
padding: 0 24px;
}
.mt12 { margin-top: 12px; }
.mt16 { margin-top: 16px; }
.mb12 { margin-bottom: 12px; }
.muted { color: #666; line-height: 1.6; }
.hero {
position: relative;
border: none;
border-radius: 16px;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.08);
padding: 28px;
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 22px;
align-items: start;
background: #fff;
overflow: hidden;
}
.hero::after {
position: absolute;
top: -12px;
left: 24px;
background: linear-gradient(45deg, #1890ff, #52c41a);
color: #fff;
padding: 6px 16px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
}
.heroLeft, .heroRight { min-width: 0; }
.heroKicker {
font-size: 0.95rem;
color: #666;
margin-bottom: 6px;
}
.heroTitle {
margin: 0;
font-size: 1.85rem;
font-weight: 700;
color: #0d1b52;
line-height: 1.15;
}
.heroText {
margin-top: 10px;
font-size: 1.05rem;
color: #666;
}
.heroFacts {
margin-top: 16px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.fact {
border: 1px solid rgba(13, 27, 82, 0.10);
background: rgba(13, 27, 82, 0.03);
border-radius: 12px;
padding: 12px;
}
.factK {
font-size: 0.9rem;
color: #666;
font-weight: 700;
}
.factV {
margin-top: 6px;
font-weight: 700;
font-size: 0.95rem;
color: #1a237e;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bankActions {
margin-top: 18px;
padding: 16px;
border-radius: 14px;
border: 1px solid rgba(26, 35, 126, 0.12);
background: linear-gradient(180deg, rgba(26, 35, 126, 0.06), rgba(13, 27, 82, 0.03));
box-shadow: 0 10px 24px rgba(0,0,0,0.06);
}
.bankHead { margin-bottom: 10px; }
.bankTitle {
margin: 0;
font-size: 1.05rem;
color: #1a237e;
font-weight: 700;
}
/* Header */
.header {
.bankSub {
margin-top: 6px;
font-size: 0.95rem;
color: #666;
line-height: 1.45;
}
.bankBtn {
height: 44px;
border-radius: 10px;
font-weight: 700;
}
.ctaCard {
border: none;
border-radius: 16px;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.08);
background: #fff;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-direction: column;
gap: 12px;
flex-wrap: wrap;
}
.header__titleRow {
.ctaHeader {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.header__title {
.ctaTitle {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--ant-colorTextHeading, #111827);
color: #0d1b52;
}
.header__subtitle {
margin-top: 4px;
color: var(--ant-colorTextSecondary, #6b7280);
font-size: 12px;
.ctaHint {
margin-top: 6px;
font-size: 0.95rem;
color: #666;
line-height: 1.45;
}
.content {
padding: 4px 0;
.statusTag {
font-weight: 700;
padding: 4px 12px;
border-radius: 999px;
white-space: nowrap;
border: 1px solid rgba(0,0,0,0.06);
background: rgba(0,0,0,0.03);
color: #666;
}
.subhead {
font-weight: 600;
margin-bottom: 10px;
color: var(--ant-colorTextHeading, #111827);
.statusTag.ok {
border-color: rgba(82, 196, 26, 0.30);
background: rgba(82, 196, 26, 0.12);
color: rgba(32, 120, 16, 1);
}
/* CTA row */
.ctaRow {
margin-top: 14px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
.statusTag.pending {
border-color: rgba(24, 144, 255, 0.30);
background: rgba(24, 144, 255, 0.12);
color: rgba(9, 74, 168, 1);
}
.statusTag.empty {
border-color: rgba(0,0,0,0.10);
background: rgba(0,0,0,0.04);
color: #777;
}
.ctaBtn {
height: 40px;
border-radius: 10px; /* un poco más friendly */
font-weight: 600;
height: 52px;
border-radius: 12px;
font-weight: 700;
}
.primaryBtn {
background: linear-gradient(135deg, #1a237e 0%, #283593 100%);
border: none;
}
.ghostBtn {
background: rgba(13, 27, 82, 0.04);
border: 1px solid rgba(13, 27, 82, 0.14);
}
.ctaDivider { margin: 14px 0 0; }
.sectionTitle {
font-size: 1.35rem;
font-weight: 700;
color: #0d1b52;
margin: 0;
}
.sectionSub {
margin-top: 8px;
font-size: 1rem;
color: #666;
}
/* Hint */
.hint {
margin-top: 10px;
color: var(--ant-colorTextSecondary, #6b7280);
font-size: 12px;
font-size: 0.95rem;
color: #666;
}
:deep(.voucher-modal .ant-modal-content),
:deep(.select-modal .ant-modal-content) {
border-radius: 16px;
}
/* Lists */
.bullets {
margin: 0 0 0 18px;
line-height: 1.7;
:deep(.voucher-modal .ant-modal),
:deep(.select-modal .ant-modal) {
max-width: 92vw;
}
.ordered {
margin: 0 0 0 18px;
line-height: 1.7;
.pay-block {
margin-top: 10px;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(13, 27, 82, 0.12);
background: rgba(13, 27, 82, 0.03);
}
/* Voucher */
.voucher-caption {
margin-bottom: 10px;
color: var(--ant-colorText, #374151);
color: #666;
line-height: 1.6;
}
.voucher-img-wrap {
background: var(--ant-colorFillAlter, #fafafa);
border: 1px solid var(--ant-colorBorderSecondary, rgba(0, 0, 0, 0.06));
background: rgba(13, 27, 82, 0.03);
border: 1px solid rgba(13, 27, 82, 0.10);
padding: 10px;
border-radius: 10px;
border-radius: 14px;
}
.modal-actions {
@ -569,28 +845,51 @@ watch(showModal, (newVal) => {
gap: 10px;
}
/* Pay block */
.pay-block {
margin-top: 8px;
padding: 12px;
border: 1px solid var(--ant-colorBorderSecondary, rgba(0, 0, 0, 0.06));
border-radius: 10px;
background: var(--ant-colorFillAlter, #fafafa);
}
:deep(.ant-card) { border-radius: 12px; }
:deep(.ant-card-bordered) { border: 1px solid rgba(13, 27, 82, 0.10); }
.mb12 {
margin-bottom: 12px;
}
.mt12 {
margin-top: 12px;
}
@media (max-width: 576px) {
.page-card {
margin: 0;
@media (max-width: 768px) {
.hide-mobile{
display: none !important;
}
.test-modern{
padding-top: 0 !important;
padding-bottom: 24px; /* opcional */
}
.ctaBtn {
.section-container{
max-width: none;
padding: 0 !important;
margin: 0 !important;
}
.hero{
grid-template-columns: 1fr;
width: 100%;
margin: 0 !important;
border-radius: 0 !important;
padding: 14px 16px 18px !important;
}
.heroKicker{ margin-top: 0 !important; }
.heroTitle{ margin-top: 6px !important; }
.heroText{ margin-top: 8px !important; }
.section{
padding: 0 16px;
}
.heroFacts{
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 576px){
.heroFacts{
grid-template-columns: 1fr;
}
}
</style>

@ -1,4 +1,3 @@
<!-- views/Dashboard.vue -->
<template>
<div style="padding: 24px;">
<a-result

Loading…
Cancel
Save