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

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

@ -0,0 +1,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']);
});
@ -204,3 +221,7 @@ Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
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" />
<!-- 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 digital y segura</p>
<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,8 +138,8 @@
</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>
@ -107,16 +153,13 @@
<p class="convocatoria-desc">Postulantes del CEPRE</p>
<div class="card-footer">
<a-button type="link" size="small" @click="$emit('show-modal', 'cepreuna')">
<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>
<a-button type="primary" ghost size="small">Consultar</a-button>
</div>
</a-card>
<a-card class="secondary-convocatoria-card">
<div class="convocatoria-header">
<div>
@ -127,24 +170,66 @@
<a-tag class="status-tag" color="orange">PRÓXIMAMENTE</a-tag>
</div>
<p class="convocatoria-desc">Modalidad extraordinaria para perfiles específicos</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')">
<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>
<a-button type="primary" ghost size="small">Consultar</a-button>
</div>
</a-card>
</div>
</div>
</div>
</div>
</section>
<!-- 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>
</a-modal>
</template>
<script setup>
import { onMounted, ref } from "vue"
import { useWebAdmisionStore } from "../../store/web"
import {
FileTextOutlined,
DollarOutlined,
@ -153,28 +238,41 @@ 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;
padding: 40px 0;
@ -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,7 +352,6 @@ const handleConsultar = (c) => {
border-radius: 999px;
}
.convocatorias-grid {
display: grid;
grid-template-columns: 1fr;
@ -259,7 +359,6 @@ const handleConsultar = (c) => {
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);

@ -1,7 +1,9 @@
<!-- src/components/web/NewsSection.vue -->
<template>
<!-- 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,16 +68,15 @@
: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>
@ -79,22 +84,85 @@
</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"
const noticiasStore = useNoticiasPublicasStore()
defineProps({
noticias: {
type: Array,
default: () => [],
},
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,30 +202,28 @@ 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;
@ -168,25 +234,11 @@ defineProps({
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>

@ -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"
: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"
>
<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">
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,7 +407,6 @@ onUnmounted(() => {
background: #fff;
}
.modern-steps {
padding: 8px 8px;
}
@ -119,7 +431,6 @@ onUnmounted(() => {
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>

@ -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,13 +1,13 @@
// 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 } },
@ -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,15 +8,17 @@ 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
@ -29,13 +30,10 @@ export const useExamenStore = defineStore('examenStore', {
}
},
async fetchAreas(proceso_id) {
try {
this.cargando = true
const { data } = await api.get('/examen/areas', {
params: { proceso_id }
})
const { data } = await api.get('/examen/areas', { params: { proceso_id } })
this.areas = data
} catch (e) {
this.error = e.response?.data?.message || e.message
@ -59,7 +57,6 @@ export const useExamenStore = defineStore('examenStore', {
}
},
async fetchExamenActual() {
try {
this.cargando = true
@ -100,13 +97,9 @@ export const useExamenStore = defineStore('examenStore', {
}
},
async responderPregunta(preguntaId, respuesta) {
async responderPregunta(preguntaId, respuesta) {
try {
const { data } = await api.post(
`/examen/pregunta/${preguntaId}/responder`,
{ respuesta }
)
const { data } = await api.post(`/examen/pregunta/${preguntaId}/responder`, { respuesta })
const index = this.preguntas.findIndex(p => p.id === preguntaId)
@ -117,19 +110,65 @@ async responderPregunta(preguntaId, respuesta) {
}
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`)
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>

@ -1,340 +1,200 @@
<!-- DashboardPostulante.vue (test + procesos activos) -->
<template>
<a-spin :spinning="loading">
<div class="section-container">
<div class="page">
<!-- Topbar -->
<div class="topbar">
<div class="topbarLeft">
<div class="hello">Bienvenido, {{ authStore.userName}}</div>
<div class="sub">DNI: {{ authStore.userDni || 'No registrado' }}</div>
<div class="hello">Bienvenido, {{ authStore.userName }}</div>
<div class="sub">DNI: {{ authStore.userDni || "No registrado" }}</div>
</div>
<div class="topbarRight">
<div class="statusLine">
<span class="label">Estado:</span>
<a-badge :status="eligibilityUi.badge" :text="eligibilityUi.text" />
</div>
<a-button class="btnTop" @click="fetchDashboard" :loading="loading" block>
Actualizar
</a-button>
</div>
</div>
<a-divider class="softDivider" />
<!-- TEST -->
<a-card :bordered="false" class="testBox">
<a-divider />
<div class="testBox">
<div class="testHead">
<div class="testHeadLeft">
<div class="testTitle">Tu test de admisión</div>
<div class="testTitle">Tu test diagnóstico</div>
<div class="testSub">
Responde con calma. Busca un lugar tranquilo. (10 preguntas 10 min aprox.)
</div>
</div>
<div class="testHeadRight">
<a-button
type="primary"
size="large"
class="btnTest"
:disabled="!canStartTest"
block
@click="onStartTest"
>
{{ testCtaText }}
</a-button>
<div class="microHelp" v-if="!canStartTest">No disponible por el momento.</div>
</div>
Es un <b>test referencial</b> para practicar y medir tu nivel. <b>No afecta</b> tu postulación oficial.
</div>
<div class="testInfoGrid">
<div class="infoItem">
<div class="infoK">Estado</div>
<div class="infoV">
<a-badge :status="testStatusUi.badge" :text="testStatusUi.text" />
</div>
<div class="mt12">
<a-space direction="vertical" size="small">
<div class="bulletRow">
<span class="dot" />
<span>10 preguntas 10 min aprox. resultado inmediato</span>
</div>
<div class="infoItem">
<div class="infoK">Tiempo restante</div>
<div class="infoV">
<span class="strong" :class="{ warn: expiresSoon && timeRemainingText !== 'Vencido' }">
{{ timeRemainingText }}
<div class="bulletRow">
<span class="dot" />
<span>
Si tu proceso pide secuencia, también puedes usar la del pago de tu
<b>Carpeta de Postulante</b> (no pagas extra por este test).
</span>
<span v-if="expiresSoon && timeRemainingText !== 'Vencido'" class="hintInline">
(vence pronto)
</span>
</div>
</div>
<div class="infoItem">
<div class="infoK">Disponible desde</div>
<div class="infoV">
{{ state.eligibility.testAvailableAt ? fmtDate(state.eligibility.testAvailableAt) : "—" }}
</a-space>
</div>
</div>
<div class="infoItem">
<div class="infoK">Fecha límite</div>
<div class="infoV">
{{ state.eligibility.testExpiresAt ? fmtDate(state.eligibility.testExpiresAt) : "—" }}
</div>
</div>
</div>
<div class="testHeadRight">
<a-button type="primary" size="large" class="btnTest" :disabled="!canGoTest" @click="goToTest" block>
Ir al test
</a-button>
<div class="mt12">
<a-alert
v-if="state.eligibility.testStatus === 'COMPLETADO'"
type="success"
show-icon
message="¡Listo! Ya completaste el test."
description="Ahora puedes revisar los procesos disponibles."
/>
<a-alert
v-else-if="!state.eligibility.hasTestAssigned"
type="info"
show-icon
message="Aún no tienes un test asignado."
description="Vuelve a revisar en unos minutos."
/>
<a-alert
v-else
type="info"
show-icon
message="Importante"
description="Al iniciar o continuar, el tiempo puede comenzar a correr según la configuración del proceso."
/>
<div class="microHelp" v-if="!canGoTest">Aún no tienes un test asignado.</div>
</div>
<a-alert
v-if="!state.eligibility.isEligibleToApply && state.eligibility.reasons?.length"
type="warning"
show-icon
message="Requisitos pendientes"
class="mt12"
>
<template #description>
<ul class="reasons">
<li v-for="(r, idx) in state.eligibility.reasons" :key="idx">{{ r }}</li>
</ul>
</template>
</a-alert>
</div>
</a-card>
<a-divider />
<a-divider class="softDivider" />
<a-divider />
<!-- PROCESOS ACTIVOS -->
<!-- Procesos -->
<div class="section">
<div class="sectionHead">
<div>
<div class="sectionTitle">Procesos disponibles</div>
<div class="sectionSub">Revisa fechas y postula cuando esté habilitado.</div>
<div class="sectionTitle">Procesos activos</div>
<div class="sectionSub">Revisa los procesos habilitados y postula cuando corresponda.</div>
</div>
<div class="sectionRight">
<a-badge count="Nuevo" class="new-badge" />
</div>
</div>
<div class="tableWrap mt12">
<a-table
:columns="processColumns"
:data-source="state.availableProcesses"
:data-source="state.processes"
row-key="id"
:loading="loading"
:pagination="{ pageSize: 6 }"
:scroll="{ x: 720 }"
class="modernTable"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'startDate'">
{{ fmtDate(record.startDate) }}
</template>
<template v-else-if="column.key === 'endDate'">
{{ fmtDate(record.endDate) }}
<template v-if="column.key === 'status'">
<a-tag class="status-tag" :color="record.statusColor">{{ record.statusText }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag class="chip">{{ record.status }}</a-tag>
<template v-else-if="column.key === 'eligibility'">
<a-badge
:status="record.isEligible ? 'success' : 'error'"
:text="record.isEligible ? 'Apto' : 'No apto'"
/>
</template>
<template v-else-if="column.key === 'actions'">
<div class="actions">
<a-button size="small" block @click="onViewProcess(record)">Ver</a-button>
<a-button size="small" type="primary" block :disabled="!record.canApply" @click="onApply(record)">
<a-button size="small" block class="actionBtn" @click="onViewProcess(record)">Ver</a-button>
<a-button
size="small"
type="primary"
block
class="actionBtnPrimary"
:disabled="!record.canApply"
@click="onApply(record)"
>
Postular
</a-button>
</div>
<div v-if="!record.canApply" class="microHelp">Este proceso aún no permite postular.</div>
<div v-if="record.canApply === false && record.blockReason" class="microHelp">
{{ record.blockReason }}
</div>
</template>
</template>
</a-table>
</div>
</div>
<a-divider />
<!-- Mensaje cuando no hay procesos -->
<a-empty
v-if="!loading && state.processes.length === 0"
class="mt12"
description="No hay procesos activos por el momento."
/>
</div>
</a-spin>
</template>
<script setup>
import { computed, onMounted, reactive, ref, onBeforeUnmount } from "vue";
import { message, Modal } from "ant-design-vue";
import { useAuthStore } from '../../store/postulanteStore'
const authStore = useAuthStore()
const loading = ref(false);
const nowTick = ref(Date.now());
let timer = null;
const state = reactive({
applicant: { id: null, nombres: "Postulante", documento: "—" },
applications: [],
eligibility: {
isEligibleToApply: false,
reasons: [],
hasTestAssigned: true,
testStatus: "PENDIENTE",
testAvailableAt: null,
testExpiresAt: null,
testUrl: null,
},
availableProcesses: [],
});
function parseDate(val) {
if (!val) return null;
const iso = String(val).includes("T") ? String(val) : String(val).replace(" ", "T");
const d = new Date(iso);
return isNaN(d.getTime()) ? null : d;
}
function msToHuman(ms) {
if (ms <= 0) return "0m";
const totalMin = Math.floor(ms / 60000);
const d = Math.floor(totalMin / (60 * 24));
const h = Math.floor((totalMin % (60 * 24)) / 60);
const m = totalMin % 60;
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function fmtDate(val) {
const d = parseDate(val);
if (!d) return val || "—";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
}
const eligibilityUi = computed(() => {
return state.eligibility.isEligibleToApply
? { badge: "success", text: "Apto para postular" }
: { badge: "error", text: "No apto para postular" };
});
const testStatusUi = computed(() => {
const s = state.eligibility.testStatus;
if (s === "EN_PROGRESO") return { badge: "processing", text: "En progreso" };
if (s === "COMPLETADO") return { badge: "success", text: "Completado" };
if (s === "NO_DISPONIBLE") return { badge: "default", text: "No disponible" };
return { badge: "warning", text: "Pendiente" };
});
import { computed, onMounted, reactive, ref } from "vue";
import { Modal, message } from "ant-design-vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../../store/postulanteStore";
const testCtaText = computed(() => {
const s = state.eligibility.testStatus;
if (s === "EN_PROGRESO") return "Continuar test";
if (s === "COMPLETADO") return "Ver estado";
if (s === "NO_DISPONIBLE") return "No disponible";
return "Iniciar test";
});
const canStartTest = computed(() => {
const e = state.eligibility;
if (!e.hasTestAssigned) return false;
if (e.testStatus === "COMPLETADO") return false;
if (e.testStatus === "NO_DISPONIBLE") return false;
return true;
});
// Ajusta a tus rutas reales
const ROUTE_TEST_PANEL = { name: "PanelTest" }; // tu panel del test
const ROUTE_PROCESS_DETAIL = (id) => ({ name: "ProcesoDetalle", params: { id } }); // opcional
const testExpireMs = computed(() => {
const d = parseDate(state.eligibility.testExpiresAt);
if (!d) return null;
return d.getTime() - nowTick.value;
});
const router = useRouter();
const authStore = useAuthStore();
const expiresSoon = computed(() => {
const ms = testExpireMs.value;
return ms !== null && ms > 0 && ms <= 24 * 60 * 60 * 1000;
});
const loading = ref(false);
const timeRemainingText = computed(() => {
const ms = testExpireMs.value;
if (ms === null) return "—";
if (ms <= 0) return "Vencido";
return msToHuman(ms);
const state = reactive({
test: { hasAssigned: true }, // viene de tu backend/store
processes: [], // procesos activos
});
const canGoTest = computed(() => !!state.test.hasAssigned);
const processColumns = computed(() => [
{ title: "Proceso", dataIndex: "name", key: "name", ellipsis: true },
{ title: "Inicio", dataIndex: "startDate", key: "startDate", responsive: ["md"] },
{ title: "Fin", dataIndex: "endDate", key: "endDate", responsive: ["md"] },
{ title: "Estado", dataIndex: "status", key: "status", responsive: ["sm"] },
{ title: "Vacantes", dataIndex: "vacancies", key: "vacancies", responsive: ["sm"] },
{ title: "Estado", key: "status", responsive: ["sm"] },
{ title: "Aptitud", key: "eligibility", responsive: ["md"] },
{ title: "Acciones", key: "actions" },
]);
// Mock: reemplaza por tu store / api real
const api = {
async getDashboard() {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
applications: [
{ id: 1, processName: "Admisión 2025-II", status: "NO_APTO", createdAt: "2025-09-05" },
{ id: 2, processName: "Admisión 2026-I", status: "EN_REVISION", createdAt: "2026-02-03" },
{ id: 3, processName: "Admisión 2026-I", status: "APROBADO", createdAt: "2026-02-06" },
],
eligibility: {
isEligibleToApply: true,
reasons: [],
hasTestAssigned: true,
testStatus: "PENDIENTE",
testAvailableAt: "2026-02-10 09:00",
testExpiresAt: "2026-02-20 23:59",
testUrl: "portal-postulante/test",
test: { hasAssigned: true },
processes: [
{
id: 10,
name: "Admisión 2026-I",
statusText: "ABIERTO",
statusColor: "blue",
isEligible: true,
canApply: true,
blockReason: "",
},
{
id: 11,
name: "Admisión Extraordinaria 2026",
statusText: "PRONTO",
statusColor: "gold",
isEligible: false,
canApply: false,
blockReason: "Este proceso aún no permite postular.",
},
availableProcesses: [
{ id: 10, name: "Admisión 2026-I", startDate: "2026-02-01", endDate: "2026-02-20", status: "ABIERTO", vacancies: 120, canApply: true },
{ id: 11, name: "Admisión Extraordinaria 2026", startDate: "2026-03-01", endDate: "2026-03-10", status: "PRONTO", vacancies: 40, canApply: false },
],
});
}, 350);
}, 250);
});
},
async startTest() {
return new Promise((resolve) => setTimeout(resolve, 300));
},
async applyToProcess() {
async applyToProcess(processId) {
return new Promise((resolve) => setTimeout(resolve, 300));
},
};
/** ---------------------------
* Actions
* --------------------------- */
async function fetchDashboard() {
loading.value = true;
try {
const data = await api.getDashboard();
state.applicant = data.applicant;
state.applications = data.applications;
state.eligibility = data.eligibility;
state.availableProcesses = data.availableProcesses;
state.test = { ...state.test, ...data.test };
state.processes = Array.isArray(data.processes) ? data.processes : [];
} catch {
message.error("No se pudo cargar el dashboard.");
} finally {
@ -342,26 +202,26 @@ async function fetchDashboard() {
}
}
async function onStartTest() {
if (!canStartTest.value) return;
function goToTest() {
if (!canGoTest.value) return;
Modal.confirm({
title: "Test de Admisión",
content: "Al iniciar o continuar, el tiempo puede comenzar a correr según la configuración. ¿Deseas continuar?",
title: "Test diagnóstico",
content: "Este test es referencial y no afecta tu postulación. ¿Deseas continuar?",
okText: "Continuar",
cancelText: "Cancelar",
async onOk() {
try {
await api.startTest();
if (state.eligibility.testUrl) window.location.href = state.eligibility.testUrl;
} catch {
message.error("No se pudo iniciar el test.");
}
onOk() {
router.push("/portal-postulante/test");
},
});
}
async function onApply(process) {
function onViewProcess(process) {
// router.push(ROUTE_PROCESS_DETAIL(process.id));
message.info(`Abrir detalle del proceso: ${process.name}`);
}
function onApply(process) {
if (!process.canApply) return;
Modal.confirm({
@ -381,36 +241,70 @@ async function onApply(process) {
});
}
function onViewProcess(process) {
message.info(`Abrir detalle del proceso: ${process.name}`);
}
onMounted(async () => {
await fetchDashboard();
timer = setInterval(() => (nowTick.value = Date.now()), 1000);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
});
onMounted(fetchDashboard);
</script>
<style scoped>
/* =========================
BASE estilo Convocatorias
========================= */
.dashboard-modern {
position: relative;
padding: 40px 0;
font-family: "Times New Roman", Times, serif;
background: #fbfcff;
overflow: hidden;
}
.dashboard-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;
}
/* Layout container */
.page {
width: 100%;
padding: 12px;
}
.pageCard {
max-width: 1120px;
margin: 0 auto;
border-radius: 14px;
padding: 0;
}
/* Header */
/* Helpers */
.mt12 { margin-top: 12px; }
.microHelp { font-size: 0.95rem; color: #666; margin-top: 6px; }
.softDivider { margin: 18px 0; }
/* =========================
Topbar
========================= */
.topbar {
display: flex;
justify-content: space-between;
@ -420,151 +314,119 @@ onBeforeUnmount(() => {
}
.hello {
font-size: 24px;
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
font-size: 2rem;
font-weight: 700;
color: #0d1b52;
margin: 0;
}
.sub {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
margin-top: 6px;
font-size: 1rem;
color: #666;
}
.topbarRight {
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-end;
min-width: 260px;
}
.statusLine {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.label {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
justify-content: flex-end;
}
.btnTop {
min-width: 180px;
height: 46px;
border-radius: 10px;
font-weight: 700;
}
/* TEST destacado (sin degradado) */
/* =========================
Test destacado (card principal)
========================= */
.testBox {
border: 2px solid var(--ant-colorPrimary, #1677ff);
background: var(--ant-colorBgContainer, #fff);
border-radius: 12px;
padding: 16px;
position: relative;
border: none;
border-radius: 16px;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.08);
background: #fff;
}
.testBox :deep(.ant-card-body) {
padding: 28px;
}
/* Badge tipo convocatorias */
.cardBadge {
position: absolute;
top: -12px;
left: 24px;
background: linear-gradient(45deg, #1890ff, #52c41a);
color: #fff;
padding: 6px 16px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
}
.testHead {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap; /* ✅ clave para móvil */
gap: 16px;
flex-wrap: wrap;
align-items: flex-start;
}
.testTitle {
font-size: 20px;
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
font-size: 1.55rem;
font-weight: 700;
color: #1a237e;
}
.testSub {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
line-height: 1.4;
margin-top: 8px;
font-size: 1rem;
color: #666;
line-height: 1.55;
max-width: 760px;
}
.testHeadRight {
min-width: 260px;
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
align-items: flex-end;
}
.btnTest {
height: 44px;
border-radius: 10px;
font-weight: 800;
height: 52px;
border-radius: 12px;
font-weight: 700;
background: linear-gradient(135deg, #1a237e 0%, #283593 100%);
border: none;
}
.testInfoGrid {
margin-top: 12px;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
/* bullets */
.bulletRow {
display: flex;
gap: 10px;
align-items: flex-start;
font-size: 1rem;
color: #666;
line-height: 1.55;
}
.infoItem {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: var(--ant-colorFillAlter, #fafafa);
border-radius: 10px;
padding: 10px 12px;
min-width: 0;
}
.infoK {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.infoV {
margin-top: 4px;
font-weight: 800;
color: var(--ant-colorText, #111827);
word-break: break-word;
}
.strong { font-weight: 900; }
.warn { color: var(--ant-colorTextHeading, #111827); }
.hintInline {
margin-left: 6px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.reasons {
margin: 8px 0 0 18px;
line-height: 1.7;
}
.summaryBox {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: var(--ant-colorFillAlter, #fafafa);
border-radius: 12px;
padding: 14px;
}
.summaryTitle {
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
margin-bottom: 10px;
}
.summaryGrid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 12px;
}
.summaryK {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.summaryV {
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
}
.chips {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #1a237e;
margin-top: 9px;
flex: 0 0 auto;
}
/* Secciones */
/* =========================
Section header
========================= */
.sectionHead {
display: flex;
justify-content: space-between;
@ -572,93 +434,136 @@ onBeforeUnmount(() => {
flex-wrap: wrap;
align-items: flex-start;
}
.sectionRight { margin-top: 4px; }
.sectionTitle {
font-size: 20px;
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
font-size: 1.55rem;
font-weight: 700;
color: #0d1b52;
margin: 0;
}
.sectionSub {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
margin-top: 8px;
font-size: 1rem;
color: #666;
line-height: 1.55;
}
/* Badge Nuevo (como convocatorias) */
.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;
}
/* Tabla: evita romper en móvil */
/* =========================
Table "card look"
========================= */
.tableWrap {
width: 100%;
overflow-x: auto;
border-radius: 16px;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.08);
background: #fff;
padding: 8px;
}
:deep(.modernTable .ant-table) {
background: transparent;
}
:deep(.ant-table-container) {
:deep(.modernTable .ant-table-container) {
overflow-x: auto;
border-radius: 12px;
}
:deep(.modernTable .ant-table-thead > tr > th) {
background: rgba(13, 27, 82, 0.03);
color: #0d1b52;
font-weight: 700;
}
:deep(.modernTable .ant-table-tbody > tr > td) {
color: #666;
}
/* Status pill estilo convocatorias */
.status-tag {
font-weight: 700;
padding: 4px 12px;
border-radius: 999px;
white-space: nowrap;
}
/* Acciones: en móvil se apilan */
/* Actions */
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.microHelp {
margin-top: 6px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
}
.listItem {
padding-left: 0;
padding-right: 0;
}
.listTitle {
display: flex;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.listName {
.actionBtn {
border-radius: 10px;
height: 38px;
font-weight: 700;
}
.muted {
color: var(--ant-colorTextSecondary, #6b7280);
}
.mt12 { margin-top: 12px; }
.actionBtnPrimary {
border-radius: 10px;
height: 38px;
font-weight: 700;
}
/* ✅ Breakpoints reales */
/* Responsive */
@media (max-width: 768px) {
.page { padding: 8px; }
.topbarRight {
width: 100%;
min-width: 0;
align-items: stretch;
.hide-mobile{
display: none !important;
}
.btnTop {
width: 100%;
min-width: 0;
/* ✅ Quita el padding superior del wrapper (era 40px) */
.test-modern{
padding-top: 0 !important;
padding-bottom: 24px; /* opcional */
}
.testHeadRight {
width: 100%;
min-width: 0;
align-items: stretch;
/* ✅ Quita padding lateral del container para que sea edge-to-edge */
.section-container{
max-width: none;
padding: 0 !important;
margin: 0 !important;
}
.testInfoGrid {
grid-template-columns: 1fr; /* ✅ cards info en columna */
/* ✅ Card principal a todo el ancho y pegado arriba */
.hero{
grid-template-columns: 1fr;
width: 100%;
margin: 0 !important;
border-radius: 0 !important;
/* importante: sin “espacio arriba” */
padding: 14px 16px 18px !important;
}
.summaryGrid {
grid-template-columns: 1fr; /* ✅ resumen en columna */
/* ✅ Asegura que el primer texto no empuje hacia abajo */
.heroKicker{ margin-top: 0 !important; }
.heroTitle{ margin-top: 6px !important; }
.heroText{ margin-top: 8px !important; }
/* Para que la sección “Tu camino” no se pegue a los bordes */
.section{
padding: 0 16px;
}
.actions {
grid-template-columns: 1fr; /* ✅ botones uno debajo del otro */
/* Facts */
.heroFacts{
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.testBox { padding: 12px; }
.testTitle { font-size: 16px; }
@media (max-width: 576px){
.heroFacts{
grid-template-columns: 1fr;
}
}
</style>

@ -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,14 +65,19 @@
</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">
@ -90,13 +95,16 @@
:value="op.key.toString()"
class="opt"
>
<span class="optKey">{{ getLetraOpcion(op.key) }}.</span>
<span class="optText" v-html="op.texto"></span>
<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>
<a-textarea
@ -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" } },
})
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);
finalizando.value = true
if (r?.success) {
message.success("Examen finalizado");
router.push({ name: "PanelResultados", params: { examenId } });
} else {
message.error(r?.message || "No se pudo finalizar");
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) {
console.error(e);
message.error("Error al finalizar");
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 {
finalizando.value = false;
// evita doble click mientras califica
setTimeout(() => {
finalizando.value = false
}, 5000)
}
},
});
};
}
/* TIMER */
const finalizarExamenAutomaticamente = () => {

File diff suppressed because it is too large Load Diff

@ -49,7 +49,11 @@ const secuenciaTitle = computed(() => {
});
const voucherSrc = computed(() => {
const map = { caja: "/voucher-caja.png", pagalo: "/voucher-pagalo.png", bn: "/voucher-bn.png" };
const map = {
caja: "/voucher-caja.png",
pagalo: "/voucher-pagalo.png",
bn: "/voucher-bn.png",
};
return map[secuenciaTipo.value] || "/voucher-bn.png";
});
@ -105,16 +109,12 @@ const estadoAlertDesc = computed(() => {
: "Son 10 preguntas. Al finalizar verás tu resultado al instante.";
});
/** CTA */
const primaryAction = computed(() => {
if (!hasExamen.value) {
return { label: "Seleccionar área", type: "primary", loading: false, onClick: () => (showModal.value = true) };
}
if (!yaDioTest.value) {
return { label: "Iniciar test", type: "primary", loading: iniciandoExamen.value, onClick: irAlExamen };
}
return { label: "Ver resultados", type: "primary", loading: false, onClick: verResultado };
});
/** ✅ Botones separados (visibilidad) */
const canIniciar = computed(() => hasExamen.value && !yaDioTest.value);
const canVerResultados = computed(() => hasExamen.value && yaDioTest.value);
const openSeleccionArea = () => {
showModal.value = true;
};
/** Options */
const procesoOptions = computed(() =>
@ -142,6 +142,9 @@ const tipoPagoOptions = [
* IMPORTANTE:
* La condición de pago debe funcionar aunque venga 1/"1"/true/"true"
*/
const normalizeRequierePago = (v) => v === 1 || v === "1" || v === true || v === "true";
/** Si estás eligiendo proceso en el modal */
const procesoRequierePago = computed(() => {
const pid = formState.proceso_id;
if (pid === undefined || pid === null || pid === "") return false;
@ -149,10 +152,31 @@ const procesoRequierePago = computed(() => {
const proceso = (examenStore.procesos || []).find((p) => String(p.id) === String(pid));
if (!proceso) return false;
const v = proceso.requiere_pago;
return v === 1 || v === "1" || v === true || v === "true";
return normalizeRequierePago(proceso.requiere_pago);
});
/** Si ya tienes examen asignado */
const examenRequierePago = computed(() => {
const v = examenStore.examenActual?.proceso?.requiere_pago;
return normalizeRequierePago(v);
});
/** ✅ Solo para mostrar un texto informativo */
const requierePagoInfo = computed(() => procesoRequierePago.value || examenRequierePago.value);
/** ✅ Contexto para jóvenes (texto claro y tranquilizador) */
const textoReferencial = computed(() => ({
message: "✅ Test referencial (no afecta tu postulación)",
description:
"Este test solo te ayuda a medir tu nivel y practicar. No cambia tu puntaje ni tu postulación oficial.",
}));
const textoCarpeta = computed(() => ({
message: "💳 Secuencia: también sirve la de tu Carpeta de Postulante",
description:
"Si tu proceso pide secuencia, puedes usar la misma del pago de la Carpeta de Postulante. No pagas nada extra por este test.",
}));
/** Validación (condicional) */
const rules = {
proceso_id: [{ required: true, message: "Selecciona un proceso", trigger: "change" }],
@ -244,12 +268,9 @@ const crearExamen = async () => {
resetModal();
await examenStore.fetchExamenActual();
} else {
// si backend responde errors, intenta mostrarlo
const msg =
result?.message ||
(result?.errors
? Object.values(result.errors).flat().join(" ")
: "No se pudo asignar el área");
(result?.errors ? Object.values(result.errors).flat().join(" ") : "No se pudo asignar el área");
message.error(msg);
}
} catch (e) {
@ -307,10 +328,6 @@ const resetModal = () => {
formRef.value?.clearValidate?.();
};
/**
* Solo resetea cuando CIERRAS modal (no cuando cambia proceso)
* IMPORTANTE: usamos watch(showModal) y NO tocamos showModal desde otros watchers
*/
watch(showModal, (open) => {
if (!open) resetModal();
});
@ -328,35 +345,30 @@ onMounted(async () => {
</script>
<template>
<!-- OJO: loading del card SOLO usa loadingPage, NO uses examenStore.cargando -->
<a-card :loading="loadingPage" class="pageCard" :bordered="true">
<!-- Header -->
<template #title>
<div class="topbar">
<div class="topbarLeft">
<div class="titleRow">
<span class="title">Test diagnóstico</span>
<span class="statusPill">{{ estadoTexto }}</span>
</div>
<div class="subtitle">
Guía referencial para medir tu nivel. <b>No afecta</b> tu admisión.
</div>
</div>
<!-- WRAPPER estilo Convocatorias -->
<div class="topbarRight">
<a-button @click="refrescar" class="btnTop" block>Actualizar</a-button>
</div>
</div>
</template>
<div class="heroKicker">Test diagnóstico</div>
<div class="heroTitle">Practica y conoce tu nivel</div>
<div class="heroText">10 preguntas 10 min aprox. Resultado inmediato</div>
<!-- HERO -->
<section class="hero">
<div class="heroLeft">
<div class="heroKicker">Tu evaluación</div>
<div class="heroTitle">Aquí está tu test</div>
<div class="heroText">10 preguntas 10 min aprox. Resultado inmediato</div>
<div class="heroFacts">
<a-space direction="vertical" size="middle" class="mt16" style="width: 100%">
<a-card size="small">
<a-space align="start">
<div>
<div style="font-weight: 700; color:#1a237e;">Test referencial (no afecta tu postulación)</div>
<div class="muted">
Este test te ayuda a medir tu nivel y practicar. No cambia tu puntaje ni tu postulación oficial.
</div>
</div>
</a-space>
</a-card>
</a-space>
<div class="heroFacts hide-mobile">
<div class="fact">
<div class="factK">Proceso</div>
<div class="factV">{{ procesoNombre }}</div>
@ -371,51 +383,76 @@ onMounted(async () => {
</div>
</div>
<a-alert
class="mt12"
:type="estadoAlertType"
show-icon
:message="estadoAlertMessage"
:description="estadoAlertDesc"
/>
<!-- BOTONES DE BANCO -->
<div class="bankActions">
<div class="bankHead">
<div class="bankTitle">Ver ejemplos de Secuencia</div>
<div class="bankSub">
Te sirve si el proceso te pide secuencia (puede ser la de la Carpeta de Postulante).
</div>
</div>
<a-space wrap>
<a-button class="bankBtn" @click="openSecuencia('caja')">Caja</a-button>
<a-button class="bankBtn" @click="openSecuencia('pagalo')">pagalo.pe</a-button>
<a-button class="bankBtn" @click="openSecuencia('bn')">Banco Nación</a-button>
</a-space>
</div>
</div>
<div class="heroRight">
<div class="ctaCard">
<div class="ctaTitle">Acción</div>
<div class="ctaHeader">
<div>
<div class="ctaTitle">Acción rápida</div>
<div class="ctaHint">
<span v-if="!hasExamen">Primero selecciona tu área.</span>
<span v-else-if="hasExamen && !yaDioTest">Inicia cuando estés listo.</span>
<span v-else>Puedes ver tus resultados.</span>
<span v-if="!hasExamen">Elige tu área y genera tu test.</span>
<span v-else-if="hasExamen && !yaDioTest">Cuando estés listo, empieza.</span>
<span v-else>Revisa tu resultado cuando quieras.</span>
</div>
</div>
<div class="statusTag" :class="{ ok: yaDioTest, pending: hasExamen && !yaDioTest, empty: !hasExamen }">
{{ estadoTexto }}
</div>
</div>
<a-button type="default" size="large" @click="openSeleccionArea" class="ctaBtn ghostBtn" block>
Seleccionar área
</a-button>
<a-button
:type="primaryAction.type"
v-if="canIniciar"
type="primary"
size="large"
:loading="primaryAction.loading"
@click="primaryAction.onClick"
class="ctaBtn"
:loading="iniciandoExamen"
@click="irAlExamen"
class="ctaBtn primaryBtn"
block
>
{{ primaryAction.label }}
Iniciar test
</a-button>
<a-button v-if="procesoRequierePago" type="link" class="ctaLink" @click="openSecuencia('bn')">
¿Dónde veo mi secuencia?
<a-button
v-if="canVerResultados"
type="primary"
size="large"
@click="verResultado"
class="ctaBtn primaryBtn"
block
>
Ver resultados
</a-button>
<a-divider class="ctaDivider" />
<div class="miniGrid">
<div class="mini">
<div class="miniK">Estado</div>
<div class="miniV">{{ estadoTexto }}</div>
</div>
<div class="mini">
<div class="miniK">Consejo</div>
<div class="miniV">Responde con calma.</div>
</div>
</div>
<a-alert
class="mt12"
:type="estadoAlertType"
show-icon
:message="estadoAlertMessage"
:description="estadoAlertDesc"
/>
</div>
</div>
</section>
@ -425,7 +462,7 @@ onMounted(async () => {
<!-- Progreso -->
<section class="section">
<div class="sectionTitle">Tu camino</div>
<div class="sectionSub">Paso a paso, sin complicaciones.</div>
<div class="sectionSub">Simple, rápido y sin estrés.</div>
<a-steps :current="stepCurrent" size="small" class="mt12">
<a-step title="Selecciona área" description="Elige proceso y área." />
@ -434,7 +471,7 @@ onMounted(async () => {
</a-steps>
<div class="hint mt12">
<span v-if="!hasExamen">Tip: selecciona un área y luego podrás iniciar.</span>
<span v-if="!hasExamen">Tip: elige tu área para generar el test.</span>
<span v-else-if="hasExamen && !yaDioTest">Tip: responde tranquilo, sin apuro.</span>
<span v-else>Tip: puedes volver a ver tus resultados cuando quieras.</span>
</div>
@ -442,37 +479,6 @@ onMounted(async () => {
<a-divider />
<!-- FAQ -->
<section class="section">
<div class="sectionTitle">Preguntas frecuentes</div>
<div class="sectionSub">Respuestas cortas y claras.</div>
<a-collapse accordion class="mt12">
<a-collapse-panel key="1" header="¿Para qué sirve este test?">
<ul class="bullets">
<li>Te ayuda a medir tu preparación antes del examen.</li>
<li>No cuenta como nota de admisión.</li>
<li>Al terminar ves tu resultado y recomendaciones.</li>
</ul>
</a-collapse-panel>
<a-collapse-panel v-if="procesoRequierePago" key="2" header="Secuencia de pago (si tu proceso lo requiere)">
<a-alert
type="info"
show-icon
message="El test es gratuito"
description="Solo se te pedirá la secuencia del pago de la Carpeta de Postulante (si tu proceso la requiere)."
class="mb12"
/>
<a-space wrap>
<a-button @click="openSecuencia('caja')">Caja</a-button>
<a-button @click="openSecuencia('pagalo')">pagalo.pe</a-button>
<a-button @click="openSecuencia('bn')">Banco Nación</a-button>
</a-space>
</a-collapse-panel>
</a-collapse>
</section>
<!-- ================== MODAL SECUENCIA ================== -->
<a-modal
v-model:open="secuenciaModalOpen"
@ -546,7 +552,7 @@ onMounted(async () => {
placeholder="Selecciona un proceso"
:options="procesoOptions"
@change="handleProcesoChange"
:loading="examenStore.cargando"
:loading="loadingPage"
/>
</a-form-item>
@ -560,18 +566,19 @@ onMounted(async () => {
/>
</a-form-item>
<!-- CAMPOS DE PAGO: aparecen sin cerrar modal -->
<div v-if="procesoRequierePago" class="pay-block">
<a-alert message="Este proceso requiere pago" type="info" show-icon class="mb12" />
<a-form-item label="Tipo de Pago" name="tipo_pago">
<a-select
v-model:value="formState.tipo_pago"
placeholder="Selecciona tipo de pago"
:options="tipoPagoOptions"
<a-alert
message="Este proceso requiere secuencia"
type="info"
show-icon
class="mb12"
description="Puedes usar la secuencia del pago de tu Carpeta de Postulante (si ya la pagaste)."
/>
<a-form-item label="Tipo de Pago" name="tipo_pago">
<a-select v-model:value="formState.tipo_pago" placeholder="Selecciona tipo de pago" :options="tipoPagoOptions" />
</a-form-item>
<a-form-item label="Código de Pago" name="codigo_pago">
<a-input v-model:value="formState.codigo_pago" placeholder="Escribe el código de pago" />
<a-input v-model:value="formState.codigo_pago" placeholder="Escribe la secuencia / código" />
</a-form-item>
</div>
</a-form>
@ -585,227 +592,310 @@ onMounted(async () => {
</a-space>
</template>
</a-modal>
</a-card>
</template>
<style scoped>
/* =========================
Base (formal, 17+, sin degradados)
BASE (estilo Convocatorias)
========================= */
.pageCard {
max-width: 1100px;
.test-modern {
position: relative;
padding: 40px 0;
font-family: "Times New Roman", Times, serif;
background: #fbfcff;
overflow: hidden;
}
.test-modern::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
repeating-linear-gradient(
to right,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 24px
),
repeating-linear-gradient(
to bottom,
rgba(13, 27, 82, 0.06) 0,
rgba(13, 27, 82, 0.06) 1px,
transparent 1px,
transparent 24px
);
opacity: 0.55;
}
.section-container {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
border-radius: 14px;
padding: 0 24px;
}
/* Helpers */
.mt12 { margin-top: 12px; }
.mt16 { margin-top: 16px; }
.mb12 { margin-bottom: 12px; }
.muted { color: #666; line-height: 1.6; }
/* Header */
.topbar {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
}
.titleRow {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.title {
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
/* =========================
HERO (tarjeta principal tipo convocatorias)
========================= */
.hero {
position: relative;
border: none;
border-radius: 16px;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.08);
padding: 28px;
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 22px;
align-items: start;
background: #fff;
overflow: hidden;
}
.statusPill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
/* Badge "Principal" estilo convocatorias */
.hero::after {
position: absolute;
top: -12px;
left: 24px;
background: linear-gradient(45deg, #1890ff, #52c41a);
color: #fff;
padding: 6px 16px;
border-radius: 999px;
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.08));
background: var(--ant-colorFillAlter, #fafafa);
font-size: 12px;
font-size: 0.75rem;
font-weight: 700;
color: var(--ant-colorText, #111827);
}
.subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
line-height: 1.35;
}
.topbarRight {
min-width: 220px;
display: flex;
justify-content: flex-end;
}
.btnTop {
border-radius: 10px;
}
/* HERO (sin degradado) */
.hero {
border: 2px solid var(--ant-colorPrimary, #1677ff);
border-radius: 14px;
padding: 16px;
background: var(--ant-colorBgContainer, #fff);
display: grid;
grid-template-columns: 1.4fr 0.9fr;
gap: 14px;
}
.heroLeft, .heroRight { min-width: 0; }
.heroKicker {
font-size: 12px;
letter-spacing: 0.2px;
color: var(--ant-colorTextSecondary, #6b7280);
font-weight: 700;
font-size: 0.95rem;
color: #666;
margin-bottom: 6px;
}
.heroTitle {
margin-top: 4px;
font-size: 20px;
margin: 0;
font-size: 1.85rem;
font-weight: 700;
color: #0d1b52;
line-height: 1.15;
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
}
.heroText {
margin-top: 6px;
font-size: 13px;
color: var(--ant-colorText, #374151);
margin-top: 10px;
font-size: 1.05rem;
color: #666;
}
/* Facts */
.heroFacts {
margin-top: 12px;
margin-top: 16px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
gap: 12px;
}
.fact {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: var(--ant-colorFillAlter, #fafafa);
border: 1px solid rgba(13, 27, 82, 0.10);
background: rgba(13, 27, 82, 0.03);
border-radius: 12px;
padding: 10px 12px;
min-width: 0;
padding: 12px;
}
.factK {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
font-size: 0.9rem;
color: #666;
font-weight: 700;
}
.factV {
margin-top: 4px;
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
margin-top: 6px;
font-weight: 700;
font-size: 0.95rem;
color: #1a237e;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* CTA card */
.ctaCard {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: var(--ant-colorFillAlter, #fafafa);
/* Banco */
.bankActions {
margin-top: 18px;
padding: 16px;
border-radius: 14px;
padding: 14px;
height: 100%;
border: 1px solid rgba(26, 35, 126, 0.12);
background: linear-gradient(180deg, rgba(26, 35, 126, 0.06), rgba(13, 27, 82, 0.03));
box-shadow: 0 10px 24px rgba(0,0,0,0.06);
}
.bankHead { margin-bottom: 10px; }
.bankTitle {
margin: 0;
font-size: 1.05rem;
color: #1a237e;
font-weight: 700;
}
.bankSub {
margin-top: 6px;
font-size: 0.95rem;
color: #666;
line-height: 1.45;
}
.bankBtn {
height: 44px;
border-radius: 10px;
font-weight: 700;
}
/* CTA */
.ctaCard {
border: none;
border-radius: 16px;
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.08);
background: #fff;
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
gap: 12px;
}
.ctaHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.ctaTitle {
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
margin: 0;
font-size: 1.1rem;
font-weight: 700;
color: #0d1b52;
}
.ctaHint {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
line-height: 1.35;
margin-top: 6px;
font-size: 0.95rem;
color: #666;
line-height: 1.45;
}
.ctaBtn {
height: 44px;
border-radius: 12px;
font-weight: 900;
/* Status pill */
.statusTag {
font-weight: 700;
padding: 4px 12px;
border-radius: 999px;
white-space: nowrap;
border: 1px solid rgba(0,0,0,0.06);
background: rgba(0,0,0,0.03);
color: #666;
}
.ctaLink {
padding: 0;
height: auto;
text-align: left;
.statusTag.ok {
border-color: rgba(82, 196, 26, 0.30);
background: rgba(82, 196, 26, 0.12);
color: rgba(32, 120, 16, 1);
}
.ctaDivider {
margin: 10px 0 0;
.statusTag.pending {
border-color: rgba(24, 144, 255, 0.30);
background: rgba(24, 144, 255, 0.12);
color: rgba(9, 74, 168, 1);
}
.miniGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
.statusTag.empty {
border-color: rgba(0,0,0,0.10);
background: rgba(0,0,0,0.04);
color: #777;
}
.mini {
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: #fff;
/* Botones */
.ctaBtn {
height: 52px;
border-radius: 12px;
padding: 10px 12px;
min-width: 0;
font-weight: 700;
}
.miniK {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
.primaryBtn {
background: linear-gradient(135deg, #1a237e 0%, #283593 100%);
border: none;
}
.miniV {
margin-top: 4px;
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
.ghostBtn {
background: rgba(13, 27, 82, 0.04);
border: 1px solid rgba(13, 27, 82, 0.14);
}
/* Sections */
.ctaDivider { margin: 14px 0 0; }
/* Secciones inferiores */
.sectionTitle {
font-weight: 900;
color: var(--ant-colorTextHeading, #111827);
font-size: 1.35rem;
font-weight: 700;
color: #0d1b52;
margin: 0;
}
.sectionSub {
margin-top: 4px;
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
margin-top: 8px;
font-size: 1rem;
color: #666;
}
.hint {
font-size: 12px;
color: var(--ant-colorTextSecondary, #6b7280);
margin-top: 10px;
font-size: 0.95rem;
color: #666;
}
.bullets, .ordered {
margin: 0 0 0 18px;
line-height: 1.7;
/* Modales */
:deep(.voucher-modal .ant-modal-content),
:deep(.select-modal .ant-modal-content) {
border-radius: 16px;
}
:deep(.voucher-modal .ant-modal),
:deep(.select-modal .ant-modal) {
max-width: 92vw;
}
/* Pago */
.pay-block {
margin-top: 10px;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(13, 27, 82, 0.12);
background: rgba(13, 27, 82, 0.03);
}
/* Voucher */
.voucher-caption {
margin-bottom: 10px;
color: var(--ant-colorText, #374151);
color: #666;
line-height: 1.6;
}
.voucher-img-wrap {
background: var(--ant-colorFillAlter, #fafafa);
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
background: rgba(13, 27, 82, 0.03);
border: 1px solid rgba(13, 27, 82, 0.10);
padding: 10px;
border-radius: 12px;
border-radius: 14px;
}
.modal-actions {
@ -815,42 +905,61 @@ onMounted(async () => {
gap: 10px;
}
/* Pay block */
.pay-block {
margin-top: 8px;
padding: 12px;
border: 1px solid var(--ant-colorBorderSecondary, rgba(0,0,0,.06));
border-radius: 12px;
background: var(--ant-colorFillAlter, #fafafa);
}
/* Opcional: cards ant-design más “suaves” */
:deep(.ant-card) { border-radius: 12px; }
:deep(.ant-card-bordered) { border: 1px solid rgba(13, 27, 82, 0.10); }
/* Responsive */
@media (max-width: 992px) {
.hero {
grid-template-columns: 1fr;
/* =========================
FULL WIDTH + TOP 0 EN MÓVIL
========================= */
@media (max-width: 768px) {
.hide-mobile{
display: none !important;
}
.heroFacts {
grid-template-columns: 1fr;
/* ✅ Quita el padding superior del wrapper (era 40px) */
.test-modern{
padding-top: 0 !important;
padding-bottom: 24px; /* opcional */
}
/* ✅ Quita padding lateral del container para que sea edge-to-edge */
.section-container{
max-width: none;
padding: 0 !important;
margin: 0 !important;
}
.miniGrid {
/* ✅ Card principal a todo el ancho y pegado arriba */
.hero{
grid-template-columns: 1fr;
width: 100%;
margin: 0 !important;
border-radius: 0 !important;
/* importante: sin “espacio arriba” */
padding: 14px 16px 18px !important;
}
}
@media (max-width: 576px) {
.pageCard { margin: 0; }
.topbarRight { width: 100%; min-width: 0; }
.btnTop { width: 100%; }
}
/* ✅ Asegura que el primer texto no empuje hacia abajo */
.heroKicker{ margin-top: 0 !important; }
.heroTitle{ margin-top: 6px !important; }
.heroText{ margin-top: 8px !important; }
/* Modales no se salen en móvil */
:deep(.voucher-modal .ant-modal),
:deep(.select-modal .ant-modal) {
max-width: 92vw;
/* Para que la sección “Tu camino” no se pegue a los bordes */
.section{
padding: 0 16px;
}
/* Facts */
.heroFacts{
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
:deep(.voucher-modal .ant-modal-content),
:deep(.select-modal .ant-modal-content) {
border-radius: 14px;
@media (max-width: 576px){
.heroFacts{
grid-template-columns: 1fr;
}
}
</style>

Loading…
Cancel
Save