main
Elmer Yujra Condori 2 months ago
parent 489cfd8f6b
commit 9cbd6d2a88

@ -0,0 +1,183 @@
<?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();
// filtros opcionales
if ($request->filled('publicado')) {
$query->where('publicado', $request->boolean('publicado'));
}
if ($request->filled('categoria')) {
$query->where('categoria', $request->string('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(),
'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,
]);
}
// GET /api/noticias/{noticia}
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'],
'imagen' => ['nullable', 'file', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
'imagen_path' => ['nullable', 'string', 'max:255'],
'link_url' => ['nullable', 'string', 'max:600'],
'link_texto' => ['nullable', 'string', 'max:120'],
'fecha_publicacion' => ['nullable', 'date'],
'publicado' => ['nullable', 'boolean'],
'destacado' => ['nullable', 'boolean'],
'orden' => ['nullable', 'integer'],
]);
// slug por defecto
if (empty($data['slug'])) {
$data['slug'] = Str::slug($data['titulo']);
}
// subir imagen si viene
if ($request->hasFile('imagen')) {
$path = $request->file('imagen')->store('noticias', 'public');
$data['imagen_path'] = $path;
}
// 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,
], 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'],
'imagen' => ['sometimes', 'nullable', 'file', 'mimes:jpg,jpeg,png,webp', 'max:4096'],
'imagen_path' => ['sometimes', 'nullable', 'string', 'max:255'],
'link_url' => ['sometimes', 'nullable', 'string', '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, reemplazar
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;
}
// 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)
{
// opcional: borrar imagen al eliminar
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',
]);
}
}

@ -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'],

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

@ -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,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
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',
'link_url',
'link_texto',
'fecha_publicacion',
'publicado',
'destacado',
'orden',
];
protected $casts = [
'fecha_publicacion' => 'datetime',
'publicado' => 'boolean',
'destacado' => 'boolean',
'orden' => 'integer',
];
protected $appends = ['imagen_url'];
public function getImagenUrlAttribute(): ?string
{
if (!$this->imagen_path) return null;
return asset('storage/' . ltrim($this->imagen_path, '/'));
}
// Auto-generar slug si no viene
protected static function booted(): void
{
static::saving(function (Noticia $noticia) {
if (!$noticia->slug) {
$noticia->slug = Str::slug($noticia->titulo);
}
});
}
}

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

@ -158,16 +158,6 @@ public function guardarRespuesta(PreguntaAsignada $pa, ?string $respuesta): arra
}
/**
* Finalizar examen
*/
public function finalizarExamen(Examen $examen): void
{
$examen->update([
'estado' => 'finalizado',
'hora_fin' => now()
]);
}
private function mezclarOpciones(?array $opciones): array
{

@ -16,9 +16,9 @@ use App\Http\Controllers\Administracion\ReglaAreaProcesoController;
use App\Http\Controllers\Administracion\ProcesoAdmisionController;
use App\Http\Controllers\Administracion\ProcesoAdmisionDetalleController;
use App\Http\Controllers\Administracion\PostulanteController;
use App\Http\Administracion\CalificacionController;
use App\Models\ProcesoAdmisionDetalle;
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();
@ -65,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}', [NoticiaController::class, 'showPublic']);
Route::middleware(['auth:sanctum'])->prefix('admin')->group(function () {
Route::get('/cursos', [CursoController::class, 'index']);
@ -178,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']);
});
@ -203,4 +220,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']);

@ -9,27 +9,18 @@
<ProcessSection />
<ConvocatoriasSection
@show-modal="showModal"
@open-preinscripcion="openPreinscripcion"
/>
<ConvocatoriasSection/>
<ProgramasSection :facultades="facultades" />
<ProgramasSection/>
<StatsSection />
<NoticiasSection :noticias="noticias" />
<NoticiasSection/>
<ModalidadesSection :modalidades="modalidades" />
<ContactSection />
</div>
<PreinscripcionModal
v-model:visible="preinscripcionModalVisible"
:facultades="facultades"
@submit="submitPreinscripcion"
/>
<FooterModerno />
</template>
@ -50,168 +41,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 preinscripcionModalVisible = ref(false)
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")
@ -223,18 +55,6 @@ const openVirtualTour = () => {
window.open("https://example.com", "_blank", "noopener,noreferrer")
}
const openPreinscripcion = () => {
preinscripcionModalVisible.value = true
}
const showModal = (type) => {
console.log("Mostrar modal:", type)
}
const submitPreinscripcion = () => {
message.success("Preinscripción iniciada exitosamente")
preinscripcionModalVisible.value = false
}
</script>
<style scoped>

@ -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,48 +12,89 @@
</p>
</div>
<div class="convocatorias-grid">
<a-card class="main-convocatoria-card">
<a-skeleton v-if="store.loading" active :paragraph="{ rows: 8 }" />
<div v-else class="convocatorias-grid">
<!-- PRINCIPAL -->
<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>Admisión Ordinaria 2026-I</h3>
<p class="convocatoria-date">Inscripciones: 20 Oct - 30 Nov</p>
<h3>{{ store.procesoPrincipal.titulo }}</h3>
<a-divider class="custom-divider" />
<p class="convocatoria-date">
PreInscripciones:
{{ formatFecha(store.procesoPrincipal.fecha_inicio_preinscripcion) }}
-
{{ formatFecha(store.procesoPrincipal.fecha_fin_inscripcion) }}
</p>
<p class="convocatoria-date">
Inscripciones:
{{ formatFecha(store.procesoPrincipal.fecha_inicio_inscripcion) }}
-
{{ formatFecha(store.procesoPrincipal.fecha_fin_inscripcion) }}
</p>
<p class="convocatoria-date">
Examen:
{{ formatFecha(store.procesoPrincipal.fecha_examen1) }}
</p>
</div>
<a-tag color="success" class="status-tag">Abierto</a-tag>
<a-tag color="success" class="status-tag">
{{ store.procesoPrincipal.estado }}
</a-tag>
</div>
<p class="convocatoria-desc">
Proceso de admisión general para todas las carreras profesionales de pregrado.
Examen de conocimientos: 15 de diciembre.
{{ 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', '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', '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', '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', 'cronograma')">
<a-button
v-if="tieneTipo('cronograma')"
class="action-btn"
@click="abrirPorTipo('cronograma')"
>
<template #icon><CalendarOutlined /></template>
Cronograma
</a-button>
@ -62,17 +103,20 @@
<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>
<!-- PREINSCRIPCION -->
<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>
<!-- Botón centrado abajo y estilo igual a Portal del Postulante -->
<div v-if="store.procesoPrincipal?.link_preinscripcion" class="preinscripcion-btn-wrap">
<a-button
type="primary"
size="large"
class="preinscripcion-btn"
@click="$emit('open-preinscripcion')"
:href="store.procesoPrincipal.link_preinscripcion"
target="_blank"
>
<template #icon><FormOutlined /></template>
Iniciar Preinscripción
@ -81,6 +125,8 @@
</div>
</div>
<div class="main-card-media">
<a-image
src="/images/extra.jpg"
@ -92,59 +138,98 @@
</div>
</a-card>
<div class="secondary-list">
<!-- SECUNDARIAS (hardcode por ahora) -->
<div class="secondary-list">
<a-card class="secondary-convocatoria-card">
<div class="convocatoria-header">
<div>
<h4 class="secondary-title">CEPREUNA</h4>
<p class="convocatoria-date">30 de enero</p>
</div>
<a-card class="secondary-convocatoria-card">
<div class="convocatoria-header">
<div>
<h4 class="secondary-title">CEPREUNA</h4>
<p class="convocatoria-date">30 de enero</p>
</div>
<a-tag class="status-tag" color="default">FINALIZADO</a-tag>
</div>
<a-tag class="status-tag" color="default">FINALIZADO</a-tag>
</div>
<p class="convocatoria-desc">Postulantes del CEPRE</p>
<p class="convocatoria-desc">Postulantes del CEPRE</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 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>
<a-card class="secondary-convocatoria-card">
<div class="convocatoria-header">
<div>
<h4 class="secondary-title">Extraordinario</h4>
<p class="convocatoria-date">15 de febrero</p>
</div>
<a-tag class="status-tag" color="orange">PRÓXIMAMENTE</a-tag>
</div>
<a-card class="secondary-convocatoria-card">
<div class="convocatoria-header">
<div>
<h4 class="secondary-title">Extraordinario</h4>
<p class="convocatoria-date">15 de febrero</p>
<p class="convocatoria-desc">
Modalidad extraordinaria para perfiles específicos
</p>
<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">Consultar</a-button>
</div>
</a-card>
</div>
</div>
<a-tag class="status-tag" color="orange">PRÓXIMAMENTE</a-tag>
</div>
</section>
<p class="convocatoria-desc">Modalidad extraordinaria para perfiles específicos</p>
<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">
Consultar
</a-button>
</div>
</a-card>
</div>
</div>
<!-- MODAL (para detalles por tipo) -->
<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"
>
<h3>{{ detalle.titulo_detalle }}</h3>
<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>
</section>
</a-modal>
</template>
<script setup>
import { onMounted, ref } from "vue"
import { useWebAdmisionStore } from "../../store/web"
import {
FileTextOutlined,
DollarOutlined,
@ -153,34 +238,47 @@ import {
FormOutlined,
} from "@ant-design/icons-vue"
defineProps({
otrasConvocatorias: {
type: Array,
default: () => [],
},
})
const store = useWebAdmisionStore()
const emit = defineEmits(["show-modal", "open-preinscripcion"])
const handleConsultar = (c) => {
const modalVisible = ref(false)
const detallesSeleccionados = ref([])
const tituloModal = ref("")
if (c && typeof c.onConsultar === "function") {
c.onConsultar()
return
}
emit("show-modal", c?.modalKey ? c.modalKey : "detalle")
onMounted(() => {
store.cargarProcesos()
})
const formatFecha = (fecha) => {
if (!fecha) return ""
return new Date(fecha).toLocaleDateString("es-PE", {
day: "2-digit",
month: "short",
year: "numeric",
})
}
</script>
<style scoped>
const tieneTipo = (tipo) => {
return store.procesoPrincipal?.detalles?.some((d) => d.tipo === tipo)
}
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;
position: relative;
padding: 40px 0;
font-family: "Times New Roman", Times, serif;
background: #fbfcff;
overflow: hidden;
background: #fbfcff;
overflow: hidden;
}
.convocatorias-modern::before {
@ -190,8 +288,7 @@ const handleConsultar = (c) => {
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,
@ -208,8 +305,13 @@ const handleConsultar = (c) => {
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;
@ -218,7 +320,6 @@ const handleConsultar = (c) => {
padding: 0 24px;
}
.section-header {
text-align: center;
margin-bottom: 50px;
@ -251,15 +352,13 @@ const handleConsultar = (c) => {
border-radius: 999px;
}
.convocatorias-grid {
display: grid;
grid-template-columns: 1fr;
grid-template-columns: 1fr;
gap: 24px;
align-items: start;
}
.main-convocatoria-card {
position: relative;
border: none;
@ -283,7 +382,6 @@ const handleConsultar = (c) => {
font-weight: 700;
}
.main-card-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
@ -298,7 +396,6 @@ const handleConsultar = (c) => {
.main-card-media {
display: flex;
justify-content: flex-end;
}
.convocatoria-image :deep(img) {
@ -310,7 +407,6 @@ const handleConsultar = (c) => {
border-radius: 14px;
}
.convocatoria-header {
display: flex;
justify-content: space-between;
@ -361,7 +457,6 @@ const handleConsultar = (c) => {
font-weight: 700;
}
.action-buttons-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
@ -379,10 +474,17 @@ const handleConsultar = (c) => {
}
.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 {
@ -390,14 +492,35 @@ const handleConsultar = (c) => {
color: #666;
}
.preinscripcion-btn {
background: linear-gradient(135deg, #1a237e 0%, #283593 100%);
border: none;
height: 52px;
font-weight: 700;
/* fila inferior centrada */
.preinscripcion-btn-wrap {
display: flex;
justify-content: center;
margin-top: 6px;
}
/* botón claro estilo AntDV */
.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);
@ -440,7 +563,7 @@ const handleConsultar = (c) => {
}
.secondary-list {
grid-template-columns: 1fr;
grid-template-columns: 1fr;
}
}
@ -459,4 +582,4 @@ const handleConsultar = (c) => {
grid-template-columns: 1fr;
}
}
</style>
</style>

@ -1,7 +1,9 @@
<!-- src/components/web/NewsSection.vue -->
<template>
<section class="news-section">
<!-- Si no hay noticias, NO renderiza NADA -->
<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,29 +14,27 @@
Entérate de los anuncios, resultados y novedades institucionales
</a-typography-paragraph>
</div>
</div>
<a-divider class="divider" />
<!-- Grid -->
<div v-if="mappedNoticias.length">
<a-row :gutter="[24, 24]">
<a-col
v-for="noticia in noticias"
v-for="noticia in mappedNoticias"
:key="noticia.id"
:xs="24"
:sm="12"
:lg="8"
>
<a-badge-ribbon
:text="noticia.categoria"
:color="noticia.tagColor || 'blue'"
:text="noticia.categoria || 'Noticia'"
:color="noticia.tagColor"
>
<a-card hoverable class="card" :bodyStyle="{ padding: '16px' }">
<!-- Cover -->
<template #cover>
<!-- Cover SOLO si hay imagen -->
<template v-if="noticia.imagen" #cover>
<div
class="cover"
:style="{ backgroundImage: `url(${noticia.imagen})` }"
@ -49,6 +49,12 @@
</template>
<a-space direction="vertical" size="small" class="content">
<!-- Si NO hay imagen, mostramos la fecha aquí -->
<div v-if="!noticia.imagen" class="date-inline">
<CalendarOutlined />
<span>{{ noticia.fecha }}</span>
</div>
<a-typography-title
:level="4"
class="card-title"
@ -62,39 +68,101 @@
:ellipsis="{ rows: 3, tooltip: noticia.descripcion }"
/>
<div class="actions">
<a-button type="link" class="read-more">
<a-button type="link" class="read-more" @click="handleLeerMas(noticia)">
Leer más
<ArrowRightOutlined />
</a-button>
<!-- Tag secundario opcional (si quieres mostrar algo extra)
<a-tag color="default" class="tag-soft">Institucional</a-tag>
-->
<a-tag v-if="noticia.destacado" color="gold" class="tag-soft">
Destacado
</a-tag>
</div>
</a-space>
</a-card>
</a-badge-ribbon>
</a-col>
</a-row>
</div>
</div>
</section>
</template>
<script setup>
import { CalendarOutlined, RightOutlined, ArrowRightOutlined } from "@ant-design/icons-vue"
import { computed, onMounted } from "vue"
import { CalendarOutlined, ArrowRightOutlined } from "@ant-design/icons-vue"
import { useNoticiasPublicasStore } from "../../store/noticiasPublicas.store"
defineProps({
noticias: {
type: Array,
default: () => [],
},
const noticiasStore = useNoticiasPublicasStore()
onMounted(() => {
// si ya está cargado, no vuelve a pedir
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"
// SIN imagen por defecto (NO /images/extra.jpg)
const imagen =
n.imagen_url ||
(n.imagen_path ? `http://localhost:8000/storage/${n.imagen_path}` : 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 handleLeerMas = (noticia) => {
// aquí tú decides: navegar, abrir modal, etc.
// por ahora solo lo dejamos listo para que conectes tu acción
console.log("Leer más:", noticia.slug || noticia.id)
}
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>
.news-section {
position: relative;
padding: 88px 0;
@ -134,59 +202,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: "Times New Roman", Times, serif !important;
font-weight: 900; /* fuerte para título */
font-weight: 900;
color: #111a56;
letter-spacing: -0.4px;
}
.subtitle {
margin: 8px 0 0 !important;
text-align: center;
font-family: "Times New Roman", Times, 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: "Times New Roman", Times, 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,12 +254,6 @@ defineProps({
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.12);
}
.card :deep(.ant-card-cover) {
margin: 0;
}
.cover {
position: relative;
height: 200px;
@ -225,7 +271,6 @@ defineProps({
);
}
.date-pill {
position: absolute;
left: 14px;
@ -243,9 +288,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 +316,6 @@ defineProps({
font-size: 0.98rem;
}
.actions {
display: flex;
justify-content: space-between;
@ -275,21 +328,14 @@ defineProps({
font-weight: 900;
}
.tag-soft {
border-radius: 999px;
font-weight: 700;
}
@media (max-width: 768px) {
.news-section {
padding: 64px 0;
}
.header {
flex-direction: column;
align-items: center;
text-align: center;
gap: 10px;
}
.btn-all {
margin-top: 6px;
}
}
</style>
</style>

@ -3,29 +3,111 @@
<section class="process-section" aria-labelledby="process-title">
<div class="section-container">
<div class="section-header">
<h2 id="process-title" class="section-title">Proceso de Admisión 2026</h2>
<h2 id="process-title" class="section-title">{{ tituloProceso }}</h2>
<p class="section-subtitle">
Sigue estos pasos para postular al Examen General 2026-I
¿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 title="Preinscripción Virtual" description="20 Oct - 30 Nov" />
<a-step title="Inscripción Presencial" description="1 - 5 Dic" />
<a-step title="Examen" description="15 Diciembre" />
<a-step title="Resultados" description="20 Diciembre" />
<a-step title="Control Biométrico Ingresantes" description="8 - 12 Ene" />
</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>
@ -33,22 +115,254 @@
</template>
<script setup>
import { ref, onMounted, onUnmounted } from "vue"
import { ref, computed, onMounted, onUnmounted } from "vue"
import { useWebAdmisionStore } from "../../store/web"
const currentStep = 2
const store = useWebAdmisionStore()
/** Responsive */
const isMobile = ref(false)
const checkScreen = () => {
isMobile.value = window.innerWidth < 768
}
const checkScreen = () => (isMobile.value = window.innerWidth < 768)
/** reloj (para refrescar estados “activo hoy”) */
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)
})
/** Helpers fecha */
const toDate = (value) => {
if (!value) return null
const d = new Date(value)
return isNaN(d.getTime()) ? null : d
}
const startOfDay = (d) => {
const x = new Date(d)
x.setHours(0, 0, 0, 0)
return x
}
const endOfDay = (d) => {
const x = new Date(d)
x.setHours(23, 59, 59, 999)
return x
}
const inRange = (n, start, end) => {
if (!start || !end) return false
return n >= startOfDay(start) && n <= endOfDay(end)
}
const sameDay = (a, b) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
const fmtShort = (value) => {
const d = toDate(value)
if (!d) return "Por definir"
return d.toLocaleDateString("es-PE", { day: "2-digit", month: "short" })
}
const fmtLong = (value) => {
const d = toDate(value)
if (!d) return "Por definir"
return d.toLocaleDateString("es-PE", {
day: "2-digit",
month: "short",
year: "numeric",
})
}
const fmtRange = (start, end) => {
const s = toDate(start)
const e = toDate(end)
if (s && e) return `${fmtShort(s)} ${fmtShort(e)}`
if (s && !e) return fmtLong(s)
if (!s && e) return fmtLong(e)
return "Por definir"
}
/** Título */
const tituloProceso = computed(() => {
const p = store.procesoPrincipal
if (!p) return "Proceso de Admisión"
return p.titulo || p.tipo_proceso || "Proceso de Admisión"
})
/**
* REGLA IMPORTANTE:
* Preinscripción dura hasta el fin de inscripción:
* inicio_preinscripcion -> fin_inscripcion
*/
const active = computed(() => {
const p = store.procesoPrincipal
if (!p) return { pre: false, ins: false, exa: false, res: false, bio: false }
const n = now.value
const preIni = toDate(p.fecha_inicio_preinscripcion)
const preFin = toDate(p.fecha_fin_inscripcion) || toDate(p.fecha_fin_preinscripcion)
const insIni = toDate(p.fecha_inicio_inscripcion)
const insFin = toDate(p.fecha_fin_inscripcion)
const exa = toDate(p.fecha_examen1)
const res = toDate(p.fecha_resultados)
const bioIni = toDate(p.fecha_inicio_biometrico)
const bioFin = toDate(p.fecha_fin_biometrico)
return {
pre: inRange(n, preIni, preFin),
ins: inRange(n, insIni, insFin),
exa: exa ? sameDay(n, exa) : false,
res: res ? sameDay(n, res) : false,
bio: inRange(n, bioIni, bioFin),
}
})
const noActive = computed(() => {
const a = active.value
return !a.pre && !a.ins && !a.exa && !a.res && !a.bio
})
/** Status por step: permite que PRE e INS sean "process" a la vez */
const getStepStatus = (index, p) => {
const n = now.value
const preIni = toDate(p.fecha_inicio_preinscripcion)
const preFin = toDate(p.fecha_fin_inscripcion) || toDate(p.fecha_fin_preinscripcion)
const insIni = toDate(p.fecha_inicio_inscripcion)
const insFin = toDate(p.fecha_fin_inscripcion)
const exa = toDate(p.fecha_examen1)
const res = toDate(p.fecha_resultados)
const bioIni = toDate(p.fecha_inicio_biometrico)
const bioFin = toDate(p.fecha_fin_biometrico)
const activePre = inRange(n, preIni, preFin)
const activeIns = inRange(n, insIni, insFin)
const activeExa = exa ? sameDay(n, exa) : false
const activeRes = res ? sameDay(n, res) : false
const activeBio = inRange(n, bioIni, bioFin)
const passed = (end) => (end ? n > endOfDay(end) : false)
const hasDates = [
Boolean(preIni || preFin),
Boolean(insIni || insFin),
Boolean(exa),
Boolean(res),
Boolean(bioIni || bioFin),
][index]
if (!hasDates) return "wait"
if (index === 0) return activePre ? "process" : passed(preFin) ? "finish" : "wait"
if (index === 1) return activeIns ? "process" : passed(insFin) ? "finish" : "wait"
if (index === 2) return activeExa ? "process" : passed(exa) ? "finish" : "wait"
if (index === 3) return activeRes ? "process" : passed(res) ? "finish" : "wait"
if (index === 4) return activeBio ? "process" : passed(bioFin) ? "finish" : "wait"
return "wait"
}
/** Poner un “🟢” cuando esté activo */
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),
},
]
})
/** “Qué hacer hoy” (claro para jóvenes) */
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, guarda tu constancia o captura y revisa los Requisitos del proceso.")
}
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 por Admisión.")
}
if (a.exa) {
tareas.push("📝 Hoy es el Examen: llega temprano, lleva tu DNI y sigue las indicaciones del reglamento.")
}
if (a.res) {
tareas.push("📣 Hoy salen Resultados: revisa el enlace oficial y guarda una captura/constancia.")
}
if (a.bio) {
tareas.push("✅ Si ingresaste: acércate al control biométrico dentro de las fechas indicadas.")
}
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 tareas
})
</script>
@ -85,7 +399,6 @@ onUnmounted(() => {
line-height: 1.4;
}
.process-card {
border: 1px solid #e5e7eb;
border-radius: 14px;
@ -94,32 +407,30 @@ onUnmounted(() => {
background: #fff;
}
.modern-steps {
padding: 8px 8px;
}
.modern-steps :deep(.ant-steps-item-title) {
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
white-space: normal !important;
overflow: visible !important;
text-overflow: clip !important;
max-width: none !important;
}
.modern-steps :deep(.ant-steps-item-content) {
min-width: 0;
min-width: 0;
width: 100%;
}
.modern-steps :deep(.ant-steps-item-container) {
align-items: flex-start;
align-items: flex-start;
}
.modern-steps :deep(.ant-steps-item) {
flex: 1 1 0;
flex: 1 1 0;
}
.modern-steps :deep(.ant-steps-item-title) {
font-size: 0.95rem;
font-weight: 700;
@ -134,7 +445,6 @@ onUnmounted(() => {
line-height: 1.25;
}
.modern-steps :deep(.ant-steps-item-icon) {
width: 30px;
height: 30px;
@ -142,19 +452,16 @@ onUnmounted(() => {
font-size: 13px;
}
.modern-steps :deep(.ant-steps-item-tail::after) {
height: 2px;
background: #dfe6e9;
}
.modern-steps :deep(.ant-steps-item-process .ant-steps-item-icon) {
background-color: #1e3a8a;
border-color: #1e3a8a;
}
.modern-steps :deep(.ant-steps-item-finish .ant-steps-item-icon > .ant-steps-icon) {
color: #1e3a8a;
}
@ -162,7 +469,6 @@ onUnmounted(() => {
border-color: #1e3a8a;
}
.process-note {
display: flex;
align-items: center;
@ -180,7 +486,107 @@ onUnmounted(() => {
flex-shrink: 0;
}
/* ====== Guía rápida ====== */
.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;
}
/* Responsive */
@media (max-width: 992px) {
.section-title {
font-size: 1.85rem;
@ -190,28 +596,26 @@ onUnmounted(() => {
}
}
@media (max-width: 768px) {
.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>
</style>

@ -0,0 +1,131 @@
<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 (Capítulo VI Sub-capítulo I)
</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">Asignación de vacantes</h4>
<p class="text">
Las vacantes se cubren de acuerdo con el puntaje alcanzado, hasta completar el número ofertado
por los programas de estudio, según lo dispuesto por el reglamento.
</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 con la debida anticipación.
</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,148 @@
<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 (Capítulo VI Sub-capítulo II)
</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-card class="soft-card" size="small">
<h4 class="subheading">Asignación de vacantes</h4>
<p class="text">
Las vacantes se cubren de acuerdo con el puntaje alcanzado hasta completar el número ofertado por los
programas de estudio, conforme a lo establecido en el reglamento.
</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'
import Nabvar from '../../nabvar.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>

@ -149,9 +149,9 @@ const navItems = computed(() => [
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" },
@ -171,9 +171,9 @@ const routesByKey = {
sociales: "/programas/sociales",
procesos: "/procesos",
modalidades: "/modalidades",
ordinario: "/modalidades/ordinario",
cepreuna: "/modalidades/cepreuna",
extraordinario: "/modalidades/extraordinario",
sedes: "/modalidades/sedes",
general: "/modalidades/general",
resultados: "/resultados",
}

@ -1,16 +1,16 @@
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Login from '../views/Login.vue'
import Hello from '../components/WebPage.vue'
import WebPage from '../components/WebPage.vue'
import { useUserStore } from '../store/user'
import { useAuthStore as usePostulanteStore } from '../store/postulanteStore'
const routes = [
{ path: '/', component: Hello },
{ path: '/', component: WebPage },
{ path: '/login', component: Login, meta: { guest: true } },
{
path: '/login-postulante',
name: 'login-postulante',
@ -18,6 +18,29 @@ const routes = [
meta: { guest: true },
},
{
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',
@ -138,7 +161,14 @@ const routes = [
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,33 +8,32 @@ export const useExamenStore = defineStore('examenStore', {
examenActual: null,
preguntas: [],
cargando: false,
calificando: false, // ✅ nuevo
resultado: null, // ✅ nuevo (para guardar resultados_examenes)
error: null,
}),
actions: {
async fetchProcesos() {
try {
this.cargando = true
const { data } = await api.get('/examen/procesos')
// ✅ normaliza
this.procesos = (data || []).map(p => ({
...p,
requiere_pago: p.requiere_pago === 1 || p.requiere_pago === '1' || p.requiere_pago === true
}))
} catch (e) {
this.error = e.response?.data?.message || e.message
} finally {
this.cargando = false
}
},
async fetchProcesos() {
try {
this.cargando = true
const { data } = await api.get('/examen/procesos')
this.procesos = (data || []).map(p => ({
...p,
requiere_pago: p.requiere_pago === 1 || p.requiere_pago === '1' || p.requiere_pago === true
}))
} catch (e) {
this.error = e.response?.data?.message || e.message
} finally {
this.cargando = false
}
},
async fetchAreas(proceso_id) {
try {
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
@ -59,7 +57,6 @@ export const useExamenStore = defineStore('examenStore', {
}
},
async fetchExamenActual() {
try {
this.cargando = true
@ -100,36 +97,78 @@ export const useExamenStore = defineStore('examenStore', {
}
},
async responderPregunta(preguntaId, respuesta) {
try {
const { data } = await api.post(`/examen/pregunta/${preguntaId}/responder`, { respuesta })
async responderPregunta(preguntaId, respuesta) {
try {
const { data } = await api.post(
`/examen/pregunta/${preguntaId}/responder`,
{ respuesta }
)
const index = this.preguntas.findIndex(p => p.id === preguntaId)
if (index !== -1 && data.success) {
this.preguntas[index].respuesta = respuesta
this.preguntas[index].es_correcta = data.correcta // 1, 0 o 2
this.preguntas[index].puntaje = data.puntaje
}
const index = this.preguntas.findIndex(p => p.id === preguntaId)
return data
if (index !== -1 && data.success) {
this.preguntas[index].respuesta = respuesta
this.preguntas[index].es_correcta = data.correcta // 1, 0 o 2
this.preguntas[index].puntaje = data.puntaje
}
} catch (e) {
this.error = e.response?.data?.message || e.message
return { success: false, message: this.error }
}
},
return data
} catch (e) {
this.error = e.response?.data?.message || e.message
return { success: false, message: this.error }
}
},
// ✅ NUEVO: Calificar examen
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
@ -143,6 +182,8 @@ async responderPregunta(preguntaId, respuesta) {
this.examenActual = null
this.preguntas = []
this.cargando = false
this.calificando = false // ✅ nuevo
this.resultado = null // ✅ nuevo
this.error = null
}
}

@ -0,0 +1,50 @@
// src/store/noticiasPublicas.store.js
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 {
// ✅ usa TU ruta real
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
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
},
},
})

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

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

@ -209,7 +209,7 @@
<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">
@ -218,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>
@ -425,7 +431,7 @@ const handleMenuSelect = ({ key }) => {
'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' },

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

@ -210,9 +210,11 @@ onBeforeUnmount(() => {
min-width: 240px;
}
.title {
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
font-size: 16px;
margin: 0;
font-size: 1.85rem;
font-weight: 700;
color: #0d1b52;
line-height: 1.15;
}
.subtitle {
margin-top: 4px;

@ -253,9 +253,11 @@ onBeforeUnmount(() => {
min-width: 240px;
}
.title {
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
font-size: 16px;
margin: 0;
font-size: 1.85rem;
font-weight: 700;
color: #0d1b52;
line-height: 1.15;
}
.subtitle {
margin-top: 4px;

@ -55,13 +55,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" />
@ -306,6 +299,8 @@ onUnmounted(() => {
</script>
<style scoped>
/* Tipografía institucional */
.portal-layout,
.portal-layout * {
@ -665,7 +660,34 @@ 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 ===== */

@ -65,37 +65,45 @@
</div>
<!-- Enunciado -->
<div class="enunciado" v-html="preguntaActual.enunciado"></div>
<div class="enunciado">
<MarkdownLatex :content="preguntaActual.enunciado" />
</div>
<!-- Extra -->
<div
v-if="preguntaActual.extra && preguntaActual.extra !== preguntaActual.enunciado"
class="extra"
v-html="preguntaActual.extra"
></div>
>
<MarkdownLatex :content="preguntaActual.extra" />
</div>
<!-- Respuestas -->
<div class="answer">
<!-- Opciones -->
<div v-if="tieneOpciones(preguntaActual)">
<a-radio-group
v-model:value="preguntaActual.respuestaSeleccionada"
:disabled="preguntaActual.estado === 'respondida' || guardando || finalizando"
class="radio-group"
>
<a-space direction="vertical" style="width: 100%">
<a-radio
v-for="op in preguntaActual.opcionesOrdenadas"
:key="op.key"
:value="op.key.toString()"
class="opt"
>
<span class="optKey">{{ getLetraOpcion(op.key) }}.</span>
<span class="optText" v-html="op.texto"></span>
</a-radio>
</a-space>
</a-radio-group>
</div>
<div v-if="tieneOpciones(preguntaActual)">
<a-radio-group
v-model:value="preguntaActual.respuestaSeleccionada"
:disabled="preguntaActual.estado === 'respondida' || guardando || finalizando"
class="radio-group"
>
<a-space direction="vertical" style="width: 100%">
<a-radio
v-for="op in preguntaActual.opcionesOrdenadas"
:key="op.key"
:value="op.key.toString()"
class="opt"
>
<span class="optKey">{{ getLetraOpcion(op.key) }})</span>
<span class="optText">
<MarkdownLatex :content="op.texto" />
</span>
</a-radio>
</a-space>
</a-radio-group>
</div>
<!-- Abierta -->
<div v-else>
@ -405,10 +413,10 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from "vue";
import { ref, computed, onMounted, onBeforeUnmount, watch, h } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useExamenStore } from "../../store/examen.store";
import { message, Modal } from "ant-design-vue";
import { message, Modal,Spin } from "ant-design-vue";
const route = useRoute();
const router = useRouter();
@ -423,7 +431,7 @@ const preguntasLocal = ref([]);
const indiceActual = ref(0);
const cargandoInicio = ref(false);
const initOnce = ref(false);
import MarkdownLatex from '../../views/administrador/cursos/MarkdownLatex.vue'
/* INFO EXAMEN */
const examenInfo = computed(() => {
if (!examenStore.examenActual) {
@ -561,35 +569,88 @@ const siguienteAccion = async () => {
}
};
/* FINALIZAR */
const finalizarExamen = async () => {
const examenId = route.params.examenId;
const examenId = Number(route.params.examenId)
// Modal de proceso
const modal = Modal.info({
title: "Procesando calificación",
content: h("div", { style: "display:flex;align-items:center;gap:12px;" }, [
h(Spin, { size: "large" }),
h("div", [
h("div", { style: "font-weight:600;" }, "Finalizando evaluación"),
h("div", { style: "margin-top:4px;color:rgba(0,0,0,.65);" }, "Estamos calificando sus respuestas. Por favor, espere..."),
]),
]),
maskClosable: false,
closable: false,
okButtonProps: { style: { display: "none" } },
})
try {
finalizando.value = true
const r = await examenStore.finalizarExamen(examenId)
// Caso especial: ya estaba finalizado (409)
const msg = (r?.message || "").toLowerCase()
if (!r?.success && (msg.includes("ya está finalizado") || msg.includes("ya esta finalizado"))) {
modal.update({
title: "Evaluación finalizada",
content: "El examen ya se encuentra finalizado. Redirigiendo a la página de resultados...",
okButtonProps: { style: { display: "none" } },
})
setTimeout(() => {
modal.destroy()
router.push({ name: "PanelResultados", params: { examenId } })
}, 1200)
return
}
// Otro error
if (!r?.success) {
modal.update({
title: "No se pudo finalizar",
content: r?.message || "No fue posible finalizar el examen. Inténtelo nuevamente.",
okButtonProps: { style: { display: "none" } },
})
setTimeout(() => modal.destroy(), 1800)
return
}
// Éxito normal: simular 5 segundos calificando
setTimeout(() => {
modal.update({
title: "Calificación completada",
content: "Redirigiendo a la página de resultados...",
okButtonProps: { style: { display: "none" } },
})
setTimeout(() => {
modal.destroy()
router.push({ name: "PanelResultados", params: { examenId } })
}, 900)
}, 5000)
} catch (e) {
modal.update({
title: "Error",
content: "Ocurrió un problema al finalizar el examen. Por favor, inténtelo nuevamente.",
okButtonProps: { style: { display: "none" } },
})
setTimeout(() => modal.destroy(), 2000)
} finally {
// evita doble click mientras califica
setTimeout(() => {
finalizando.value = false
}, 5000)
}
}
Modal.confirm({
title: "Finalizar examen",
content: "¿Confirmas finalizar? Luego no podrás modificar respuestas.",
okText: "Finalizar",
cancelText: "Cancelar",
onOk: async () => {
try {
finalizando.value = true;
const r = await examenStore.finalizarExamen(examenId);
if (r?.success) {
message.success("Examen finalizado");
router.push({ name: "PanelResultados", params: { examenId } });
} else {
message.error(r?.message || "No se pudo finalizar");
}
} catch (e) {
console.error(e);
message.error("Error al finalizar");
} finally {
finalizando.value = false;
}
},
});
};
/* TIMER */
const finalizarExamenAutomaticamente = () => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save