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

@ -11,15 +11,22 @@ class Examen extends Model
protected $table = 'examenes';
protected $fillable = [
protected $fillable = [
'postulante_id',
'area_proceso_id',
'pagado',
'tipo_pago',
'pago_id',
'pagado',
'tipo_pago',
'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,39 +115,49 @@ public function obtenerPreguntasExamen(Examen $examen): array
public function guardarRespuesta(PreguntaAsignada $pa, string $respuesta): array
{
if ($pa->estado === 'respondida') {
return ['success' => false, 'message' => 'Ya respondida'];
}
public function guardarRespuesta(PreguntaAsignada $pa, ?string $respuesta): array
{
if ($pa->estado === 'respondida') {
return ['success' => false, 'message' => 'Ya respondida'];
}
$esCorrecta = $respuesta === $pa->pregunta->respuesta_correcta;
// 🔹 Si está en blanco
if (empty($respuesta)) {
$pa->update([
'respuesta_usuario' => $respuesta,
'es_correcta' => $esCorrecta,
'puntaje_obtenido' => $esCorrecta ? $pa->puntaje_base : 0,
'respuesta_usuario' => null,
'es_correcta' => 2, // 2 = blanco
'puntaje_obtenido' => 0,
'estado' => 'respondida',
'respondida_at' => now()
]);
return [
'success' => true,
'correcta' => $esCorrecta,
'puntaje' => $pa->puntaje_obtenido
'correcta' => 2,
'puntaje' => 0
];
}
/**
* Finalizar examen
*/
public function finalizarExamen(Examen $examen): void
{
$examen->update([
'estado' => 'finalizado',
'hora_fin' => now()
]);
}
// 🔹 Si respondió algo
$esCorrecta = $respuesta === $pa->pregunta->respuesta_correcta;
$pa->update([
'respuesta_usuario' => $respuesta,
'es_correcta' => $esCorrecta ? 1 : 0,
'puntaje_obtenido' => $esCorrecta ? $pa->puntaje_base : 0,
'estado' => 'respondida',
'respondida_at' => now()
]);
return [
'success' => true,
'correcta' => $esCorrecta ? 1 : 0,
'puntaje' => $pa->puntaje_obtenido
];
}
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']);
});
@ -185,4 +223,8 @@ Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
Route::match(['put','patch'], '/{id}', [ProcesoAdmisionDetalleController::class, 'update'])->name('update');
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>
<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>
</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="section-header">
<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>
<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;
.illustration-box {
text-align: center;
margin-bottom: 20px;
}
.faq-answer {
color: #555;
font-size: 0.975rem;
line-height: 1.75;
padding-left: 50px;
.illustration-img {
width: 160px;
margin-bottom: 8px;
}
.faq-answer :deep(ul) {
padding-left: 20px;
margin: 0;
.illustration-text {
font-size: 0.9rem;
color: #6b7280;
}
.faq-answer :deep(ul li) {
margin-bottom: 4px;
}
.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">
{{ formatFechasInscripcion(principal) }}
Inscripciones:
{{ formatFecha(store.procesoPrincipal.fecha_inicio_inscripcion) }}
-
{{ formatFecha(store.procesoPrincipal.fecha_fin_inscripcion) }}
</p>
<p class="convocatoria-date">
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>
@ -71,17 +102,19 @@
<a-divider class="custom-divider" />
<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>
</div>
<div class="preinscripcion-section">
<div class="preinscripcion-info">
<h4 class="subheading">Preinscripción en Línea</h4>
<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>
<h1 class="hero-title">
Admisión <span class="hero-year">2026</span>
</h1>
<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">
<div class="card-header">
<CalendarOutlined />
<span>Próximo evento</span>
</div>
<h3 class="card-title">Charla informativa</h3>
<div class="card-info">
<span class="info-item">
<ClockCircleOutlined />
25 Nov 4:00 PM
</span>
<span class="info-item">
<VideoCameraOutlined />
Virtual
</span>
</div>
<a-button type="primary" ghost class="card-cta" size="middle">
Registrarse
</a-button>
<!-- CARD -->
<!-- <div class="hero-visual">
<div class="glass-card">
<div class="card-header">
<CalendarOutlined />
<span>Próximo evento</span>
</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>
<h3>Charla informativa</h3>
<p class="card-date">
<ClockCircleOutlined />
25 Nov 4:00 PM Virtual
</p>
<a-button
size="large"
class="secondary-button"
@click="$emit('virtual-tour')"
>
<template #icon><PlayCircleOutlined /></template>
Tour virtual
</a-button>
</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 */
}
/* 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;
--text: rgba(245, 247, 255, 0.92);
--muted: rgba(229, 235, 255, 0.72);
}
.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;
gap: 6px;
margin-bottom: 20px;
}
.mini-icon {
opacity: 0.9;
}
/* ====== 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>
</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,89 +13,259 @@
Entérate de los anuncios, resultados y novedades institucionales
</a-typography-paragraph>
</div>
</div>
<a-divider class="divider" />
<!-- Grid -->
<a-row :gutter="[24, 24]">
<a-col
v-for="noticia in noticias"
:key="noticia.id"
:xs="24"
:sm="12"
:lg="8"
>
<a-badge-ribbon
:text="noticia.categoria"
:color="noticia.tagColor || 'blue'"
<div v-if="mappedNoticias.length">
<a-row :gutter="[24, 24]">
<a-col
v-for="noticia in mappedNoticias"
:key="noticia.id"
:xs="24"
:sm="12"
:lg="8"
>
<a-card hoverable class="card" :bodyStyle="{ padding: '16px' }">
<!-- Cover -->
<template #cover>
<div
class="cover"
:style="{ backgroundImage: `url(${noticia.imagen})` }"
>
<div class="cover-overlay" />
<div class="date-pill">
<a-badge-ribbon
:text="noticia.categoria || 'Noticia'"
:color="noticia.tagColor"
>
<a-card
hoverable
class="card"
:bodyStyle="{ padding: '16px' }"
@click="openModal(noticia)"
>
<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>
</div>
</div>
</template>
<a-space direction="vertical" size="small" class="content">
<div v-if="!noticia.imagen" class="date-inline">
<CalendarOutlined />
<span>{{ noticia.fecha }}</span>
</div>
</div>
</template>
<a-space direction="vertical" size="small" class="content">
<a-typography-title
:level="4"
class="card-title"
:content="noticia.titulo"
:ellipsis="{ rows: 2, tooltip: noticia.titulo }"
/>
<a-typography-paragraph
class="desc"
:content="noticia.descripcion"
:ellipsis="{ rows: 3, tooltip: noticia.descripcion }"
/>
<div class="actions">
<a-button type="link" class="read-more">
Leer más
<ArrowRightOutlined />
</a-button>
<!-- Tag secundario opcional (si quieres mostrar algo extra)
<a-tag color="default" class="tag-soft">Institucional</a-tag>
-->
</div>
</a-space>
</a-card>
</a-badge-ribbon>
</a-col>
</a-row>
<a-typography-title
:level="4"
class="card-title"
:content="noticia.titulo"
:ellipsis="{ rows: 2, tooltip: noticia.titulo }"
/>
<a-typography-paragraph
class="desc"
:content="noticia.descripcion"
:ellipsis="{ rows: 3, tooltip: noticia.descripcion }"
/>
<div class="actions">
<a-button
type="link"
class="read-more"
@click.stop="openModal(noticia)"
>
Leer más
<ArrowRightOutlined />
</a-button>
<a-tag v-if="noticia.destacado" color="gold" class="tag-soft">
Destacado
</a-tag>
</div>
</a-space>
</a-card>
</a-badge-ribbon>
</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-weight: 300;
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;
}
.modal-date {
display: inline-flex;
align-items: center;
gap: 8px;
color: rgba(0, 0, 0, 0.55);
font-weight: 700;
}
.btn-all {
margin-top: 6px;
.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>
</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"
/>
</a-steps>
<div class="process-note">
:items="stepsItems"
/>
<!-- 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 inscripcion = formatRango(p.fecha_inicio_inscripcion, p.fecha_fin_inscripcion)
if (inscripcion) {
list.push({ title: 'Inscripción Presencial', description: inscripcion })
}
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 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)
const examen = formatRango(p.fecha_examen1, p.fecha_examen2)
if (examen) {
list.push({ title: 'Examen', description: examen })
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 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 resultados = formatFecha(p.fecha_resultados)
if (resultados) {
list.push({ title: 'Resultados', description: 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));
@ -196,4 +190,4 @@
font-size: 2.4rem;
}
}
</style>
</style>

@ -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,16 +1,17 @@
// 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: '/login', component: Login, meta: { guest: true } },
{ path: '/', component: WebPage },
{ path: '/account/auth/login', component: Login, meta: { guest: true } },
{
path: '/login-postulante',
name: '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`)
this.examenActual = null
this.preguntas = []
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">
@ -138,8 +137,7 @@
<span class="menu-label">Dashboard</span>
</div>
</a-menu-item>
<!-- Gestión Académica -->
<div class="menu-section" v-if="!sidebarCollapsed">
<div class="section-label">Gestión Académica</div>
</div>
@ -199,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,60 +137,68 @@
</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">
<a-tag class="info-tag">Universidad Nacional del Altiplano Puno</a-tag>
<a-typography-title :level="3" style="margin: 8px 0 0">
{{ isRegister ? "Registro de Postulante" : "Portal del Postulante" }}
</a-typography-title>
<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."
}}
</a-typography-text>
</div>
<div class="info-section">
<div class="info-section-title">
{{ isRegister ? "Al registrarte podrás" : "Al ingresar podrás" }}
</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>
<a-col :xs="24" :md="12" class="auth-pane auth-pane-info">
<div class="pane-inner pane-inner-info">
<div class="info-top">
<a-tag class="info-tag">Universidad Nacional del Altiplano Puno</a-tag>
<a-typography-title :level="3" style="margin: 8px 0 0">
{{ isRegister ? "Registro de Postulante" : "Portal del Postulante" }}
</a-typography-title>
<a-typography-text type="secondary">
{{
isRegister
? "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>
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Subir requisitos</b> y revisar observaciones.</span>
<div class="info-section">
<div class="info-section-title">
{{ isRegister ? "Al registrarte podrás" : "Al ingresar podrás" }}
</div>
<div class="info-list">
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Rendir un test de referencia</b>.</span>
</div>
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Ver procesos disponibles</b> según tu modalidad.</span>
</div>
<div class="info-item">
<span class="info-bullet"></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>Revisar comunicados oficiales</b> del proceso de admisión.</span>
</div>
</div>
</div>
<div class="info-item" v-if="!isRegister">
<span class="info-bullet"></span>
<span><b>Ver comunicados y resultados</b> del proceso.</span>
</div>
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Consultar tu estado</b> de inscripción/admisión.</span>
<div class="info-foot">
<a-typography-text type="secondary">
Plataforma oficial de admisión Soporte en horario institucional
</a-typography-text>
</div>
</div>
</div>
</a-col>
<div class="info-foot">
<a-typography-text type="secondary">
Soporte: Mesa de ayuda Atención 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,207 +1,254 @@
<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>
<a-table
class="procesos-table"
:dataSource="procesosFiltrados"
:columns="columns"
rowKey="id"
:pagination="{ pageSize: 7, showSizeChanger: false }"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<!-- Nombre -->
<template v-if="column.key === 'nombre'">
<div class="nombre">
{{ record.nombre || '-' }}
</div>
</template>
<div class="tableWrap desktopOnly">
<a-table
:dataSource="procesosFiltrados"
:columns="columns"
rowKey="id"
:pagination="{ pageSize: 7, showSizeChanger: false }"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<!-- Puntaje -->
<template v-else-if="column.key === 'puntaje'">
<span class="puntaje">
{{ record.puntaje ?? '-' }}
</span>
</template>
<template v-if="column.key === 'nombre'">
<div class="nombre">{{ record.nombre || "-" }}</div>
<div class="meta">ID: {{ record.id }}</div>
</template>
<template v-else-if="column.key === 'puntaje'">
<div class="puntaje">{{ record.puntaje ?? "-" }}</div>
<div class="meta">Puntaje</div>
</template>
<template v-else-if="column.key === 'apto'">
<span class="statusPill" :class="statusClass(record.apto)">
{{ aptoTexto(record.apto) }}
</span>
</template>
<template v-else-if="column.key === 'acciones'">
<a-button size="small" @click="verDetalle(record)">
Ver detalle
</a-button>
</template>
<!-- Apto -->
<template v-else-if="column.key === 'apto'">
<a-tag :color="aptoColor(record.apto)" class="tag-pill">
{{ aptoTexto(record.apto) }}
</a-tag>
</template>
<!-- Acciones -->
<template v-else-if="column.key === 'acciones'">
<a-space>
<a-button size="small" @click="verDetalle(record)">Ver detalle</a-button>
</a-space>
<template #emptyText>
<a-empty description="No se encontraron procesos" />
</template>
</template>
</a-table>
</div>
<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>
<template #emptyText>
<a-empty description="No se encontraron procesos" />
<div class="itemActions">
<a-button
type="primary"
block
class="btnPrimary"
@click="verDetalle(p)"
>
Ver detalle
</a-button>
</div>
</div>
</template>
</a-table>
<a-empty v-else description="No se encontraron procesos" />
</div>
</a-spin>
</a-card>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import api from '../../axiosPostulante' // ajusta la ruta a tu axios
import { ref, computed, onMounted } 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;
}
</style>
.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,202 +1,188 @@
<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 -->
<a-table
class="pagos-table"
:dataSource="pagosFiltrados"
:columns="columns"
rowKey="key"
:pagination="{ pageSize: 6, showSizeChanger: false }"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<!-- Tipo -->
<template v-if="column.key === 'tipo'">
<a-tag :color="getColor(record.tipo)" class="tag-pill">
{{ tipoLabel(record.tipo) }}
</a-tag>
<div v-if="!isMobile" class="tableWrap">
<a-table
class="table"
:dataSource="pagosFiltrados"
:columns="columns"
rowKey="key"
:pagination="{ pageSize: 6, showSizeChanger: false }"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'tipo'">
<span class="pill pill--neutral">{{ tipoLabel(record.tipo) }}</span>
</template>
<template v-else-if="column.key === 'codigo'">
<div class="codigoCell">
<div class="codigo">{{ record.codigo || "-" }}</div>
<span v-if="record.estado" class="pill pill--soft">{{ record.estado }}</span>
</div>
</template>
<template v-else-if="column.key === 'uso'">
<div class="usoCell">
<div class="usoMain">{{ getUso(record) }}</div>
<div v-if="record.proceso_nombre || record.proceso" class="usoSub">
{{ record.proceso_nombre || record.proceso }}
</div>
</div>
</template>
<template v-else-if="column.key === 'monto'">
<div class="monto">S/ {{ Number(record.monto || 0).toFixed(2) }}</div>
</template>
<template v-else-if="column.key === 'fecha_pago'">
<div class="fecha">{{ formatFecha(record.fecha_pago) }}</div>
</template>
</template>
<!-- Código -->
<template v-else-if="column.key === 'codigo'">
<div class="codigo-cell">
<span class="codigo">{{ record.codigo || '-' }}</span>
<a-tag v-if="record.estado" color="default" class="tag-mini">
{{ record.estado }}
</a-tag>
</div>
<template #emptyText>
<a-empty description="No se encontraron pagos" />
</template>
</a-table>
</div>
<!-- Uso / Proceso -->
<template v-else-if="column.key === 'uso'">
<div class="uso-cell">
<div class="uso-main">
{{ getUso(record) }}
</div>
<div v-if="record.proceso_nombre || record.proceso" class="uso-sub">
{{ record.proceso_nombre || record.proceso }}
<div v-else class="cards">
<template v-if="pagosFiltrados.length">
<div v-for="p in pagosFiltrados" :key="p.key" class="itemCard">
<div class="itemTop">
<div class="itemLeft">
<div class="itemTitle">{{ getUso(p) }}</div>
<div class="itemSub" v-if="p.proceso_nombre || p.proceso">
{{ p.proceso_nombre || p.proceso }}
</div>
</div>
<span class="pill pill--neutral">{{ tipoLabel(p.tipo) }}</span>
</div>
</template>
<!-- Monto -->
<template v-else-if="column.key === 'monto'">
<span class="monto">
S/ {{ Number(record.monto || 0).toFixed(2) }}
</span>
</template>
<div class="grid">
<div class="kv">
<div class="k">Código</div>
<div class="v strong">{{ p.codigo || "-" }}</div>
</div>
<!-- Fecha -->
<template v-else-if="column.key === 'fecha_pago'">
<span class="fecha">{{ formatFecha(record.fecha_pago) }}</span>
</template>
</template>
<div class="kv">
<div class="k">Monto</div>
<div class="v strong primary">S/ {{ Number(p.monto || 0).toFixed(2) }}</div>
</div>
<div class="kv">
<div class="k">Fecha</div>
<div class="v">{{ formatFecha(p.fecha_pago) }}</div>
</div>
<!-- Empty -->
<template #emptyText>
<a-empty description="No se encontraron pagos" />
<div class="kv" v-if="p.estado">
<div class="k">Estado</div>
<div class="v">
<span class="pill pill--soft">{{ p.estado }}</span>
</div>
</div>
</div>
</div>
</template>
</a-table>
<!-- Tip informativo -->
<a-alert
class="tip-alert"
type="warning"
show-icon
message="Nota"
description="Si un pago no muestra el campo “Uso / Proceso”, es porque aún no está asociado a un proceso en el backend."
/>
<a-empty v-else description="No se encontraron pagos" />
</div>
<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;
}
.tip-alert {
.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);
}
.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>
</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

File diff suppressed because it is too large Load Diff

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

Loading…
Cancel
Save