last_changes1

main
elmer-20 2 months ago
parent 6694500ed5
commit 8f35717dc9

@ -267,4 +267,77 @@ public function misProcesos(Request $request)
]);
}
public function obtenerAvanceProcesoPostulante(Request $request, $idProceso)
{
$postulante = $request->user();
if (!$postulante) {
return response()->json([
'success' => false,
'message' => 'No autenticado'
], 401);
}
$dni = trim($postulante->dni);
if (!$dni) {
return response()->json([
'success' => false,
'message' => 'El postulante no tiene DNI registrado'
], 422);
}
$url = "https://inscripciones.admision.unap.edu.pe/api/get-avance-proceso-postulante/{$idProceso}/{$dni}";
try {
$response = Http::timeout(15)->get($url);
if (!$response->successful()) {
return response()->json([
'success' => false,
'message' => 'No se pudo obtener el avance del proceso',
'status_http' => $response->status(),
'error' => $response->json() ?? $response->body(),
], 502);
}
$payload = $response->json();
// Tu API externa devuelve: { estado: true/false, datos: {...} }
if (!isset($payload['estado']) || $payload['estado'] !== true) {
return response()->json([
'success' => false,
'message' => 'La API devolvió estado false',
'raw' => $payload
], 404);
}
$datos = $payload['datos'] ?? [];
return response()->json([
'success' => true,
'data' => [
'dni' => $datos['dni'] ?? $dni,
'id_proceso' => $datos['id_proceso'] ?? (int)$idProceso,
'avance' => $datos['avance'] ?? null,
'estado' => $datos['estado'] ?? null,
'observacion' => $datos['observacion'] ?? '',
],
'raw' => $payload, // si no lo quieres, elimínalo
]);
} catch (\Throwable $e) {
Log::error('Error al consultar avance del proceso', [
'idProceso' => $idProceso,
'dni' => $dni,
'error' => $e->getMessage(),
]);
return response()->json([
'success' => false,
'message' => 'Error interno consultando avance del proceso'
], 500);
}
}
}

@ -83,7 +83,7 @@ class WebController extends Controller
'fecha_fin_preinscripcion',
])
->where('publicado', 1)
->whereIn('estado', ['publicado', 'en_proceso'])
->whereIn('estado', ['nuevo','publicado','en_proceso'])
->whereNotNull('link_preinscripcion')
->whereNotNull('fecha_inicio_preinscripcion')
->whereNotNull('fecha_fin_preinscripcion')

@ -13,66 +13,62 @@ class ExamenService
/**
* Generar preguntas según reglas
*/
public function generarPreguntasExamen(Examen $examen): array
{
if ($examen->preguntasAsignadas()->exists()) {
return [
'success' => false,
'message' => 'El examen ya tiene preguntas'
];
}
public function generarPreguntasExamen(Examen $examen): array
{
if ($examen->preguntasAsignadas()->exists()) {
return [
'success' => false,
'message' => 'El examen ya tiene preguntas'
];
}
$reglas = ReglaAreaProceso::where('area_proceso_id', $examen->area_proceso_id)
->orderBy('orden')
->get();
$reglas = ReglaAreaProceso::where('area_proceso_id', $examen->area_proceso_id)
->orderBy('orden')
->get();
if ($reglas->isEmpty()) {
return [
'success' => false,
'message' => 'No hay reglas configuradas'
];
}
if ($reglas->isEmpty()) {
return [
'success' => false,
'message' => 'No hay reglas configuradas'
];
}
DB::beginTransaction();
try {
$orden = 1;
foreach ($reglas as $regla) {
$preguntas = Pregunta::where('curso_id', $regla->curso_id)
->where('activo', 1)
->when($regla->nivel_dificultad, fn ($q) =>
$q->where('nivel_dificultad', $regla->nivel_dificultad)
)
->inRandomOrder()
->limit($regla->cantidad_preguntas)
->get();
if ($preguntas->count() < $regla->cantidad_preguntas) {
throw new \Exception("Preguntas insuficientes para curso {$regla->curso_id}");
}
foreach ($preguntas as $pregunta) {
PreguntaAsignada::create([
'examen_id' => $examen->id,
'pregunta_id' => $pregunta->id,
'orden' => $orden++,
'puntaje_base' => $regla->ponderacion,
'estado' => 'pendiente',
]);
}
}
DB::beginTransaction();
DB::commit();
try {
$orden = 1;
return ['success' => true];
foreach ($reglas as $regla) {
$preguntas = Pregunta::where('curso_id', $regla->curso_id)
->where('activo', 1)
// ✅ sin filtro por nivel_dificultad (trae todo)
->inRandomOrder()
->limit($regla->cantidad_preguntas)
->get();
} catch (\Throwable $e) {
DB::rollBack();
return ['success' => false, 'message' => $e->getMessage()];
if ($preguntas->count() < $regla->cantidad_preguntas) {
throw new \Exception("Preguntas insuficientes para curso {$regla->curso_id}");
}
foreach ($preguntas as $pregunta) {
PreguntaAsignada::create([
'examen_id' => $examen->id,
'pregunta_id' => $pregunta->id,
'orden' => $orden++,
'puntaje_base' => $regla->ponderacion,
'estado' => 'pendiente',
]);
}
}
}
DB::commit();
return ['success' => true];
} catch (\Throwable $e) {
DB::rollBack();
return ['success' => false, 'message' => $e->getMessage()];
}
}
public function obtenerPreguntasExamen(Examen $examen): array
{

@ -207,4 +207,9 @@ Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
Route::get('/proceso-resultado/{procesoId}/archivos', [ProcesoAdmisionResultadoArchivoController::class, 'index']);
Route::post('/proceso-resultado/{procesoId}/archivos', [ProcesoAdmisionResultadoArchivoController::class, 'store']);
Route::delete('/proceso-resultado/archivos/{id}', [ProcesoAdmisionResultadoArchivoController::class, 'destroy']);
});
});
Route::middleware('auth:sanctum')->get(
'/mis-procesos/{idProceso}/avance',
[PostulanteAuthController::class, 'obtenerAvanceProcesoPostulante']
);

@ -109,18 +109,24 @@ const routes = [
meta: { requiresAuth: true }
},
{
path: '/portal-postulante/pagos',
name: 'PanelPagos',
component: () => import('../views/postulante/Pagos.vue'),
meta: { requiresAuth: true }
},
// {
// path: '/portal-postulante/pagos',
// name: 'PanelPagos',
// component: () => import('../views/postulante/Pagos.vue'),
// meta: { requiresAuth: true }
// },
{
path: '/portal-postulante/mis-procesos',
name: 'PanelProcesos',
component: () => import('../views/postulante/MisProcesos.vue'),
meta: { requiresAuth: true }
},
{
path: '/portal-postulante/mis-procesos-estado/',
name: 'AvanceProceso',
component: () => import('../views/postulante/AvanceProceso.vue'),
meta: { requiresAuth: true }
},
]

@ -0,0 +1,271 @@
<template>
<div class="title">Seguimiento del proceso</div>
<a-card class="card" :bordered="true">
<template #title>
<div class="header">
<div class="headerLeft">
<div class="subtitle">
Proceso 31: <b>Examen General 2026-I</b>
</div>
</div>
<div class="headerRight">
<a-button @click="cargarAvance" :loading="loading" type="primary">
Actualizar
</a-button>
</div>
</div>
</template>
<a-spin :spinning="loading">
<!-- NO PARTICIPA -->
<div v-if="noParticipa" class="noBox">
<div class="noTitle">Actualmente no estás participando en ningún proceso</div>
<div class="noDesc">
Si crees que esto es un error, verifica que hayas realizado tu preinscripción o inicia sesión con el DNI correcto.
</div>
</div>
<!-- PARTICIPA -->
<template v-else>
<a-alert
v-if="avance"
:message="`Actualmente estás en la etapa de: ${avance.estado || '-'}`"
:description="indicador"
type="info"
show-icon
class="alertState"
/>
<div class="timelineWrap" v-if="avance">
<div class="timelineHeader">
<div class="chip">
<span class="chipLabel">Proceso</span>
<span class="chipValue">Examen General 2026-I</span>
</div>
<div class="chip">
<span class="chipLabel">Etapa</span>
<span class="chipValue strong">{{ avance.estado || "-" }}</span>
</div>
</div>
<div class="simpleBox">
<div class="k">Indicador</div>
<div class="v">{{ indicador }}</div>
<div v-if="avance.observacion" class="obs">
<div class="obsTitle">Observación</div>
<div class="obsText">{{ avance.observacion }}</div>
</div>
</div>
</div>
</template>
</a-spin>
</a-card>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { message } from "ant-design-vue";
import api from "../../axiosPostulante";
const loading = ref(false);
const avance = ref(null);
// cuando el backend no devuelve nada / 404 / estado false
const noParticipa = ref(false);
// fijo: Proceso 31
const idProceso = 31;
const cargarAvance = async () => {
loading.value = true;
avance.value = null;
noParticipa.value = false;
try {
const { data } = await api.get(`/mis-procesos/${idProceso}/avance`);
// Caso OK con datos
if (data?.success && data?.data) {
avance.value = data.data;
noParticipa.value = false;
return;
}
// Caso: success false o data vacío
avance.value = null;
noParticipa.value = true;
} catch (e) {
// 404 => no participa
if (e?.response?.status === 404) {
avance.value = null;
noParticipa.value = true;
return;
}
// Si tu backend manda 200 pero { success:false, message:"..." } no cae aquí.
message.error(e.response?.data?.message || "Error al cargar seguimiento");
// ante error real, decide si mostrar "no participa" o no:
noParticipa.value = true;
} finally {
loading.value = false;
}
};
const normalizar = (txt) =>
String(txt || "")
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim();
const indicador = computed(() => {
const st = normalizar(avance.value?.estado);
if (!st) return "Sigue revisando esta sección para ver el siguiente paso.";
if (st.includes("preins"))
return "Ya realizaste la preinscripción. Por ahora, espera que el sistema habilite el siguiente paso.";
if (st.includes("inscrip"))
return "Estás en inscripción final. Completa lo solicitado para generar tu constancia.";
return "Por ahora, espera que el sistema habilite el siguiente paso.";
});
onMounted(() => {
cargarAvance();
});
</script>
<style scoped>
.card {
width: 100%;
max-width: 1100px;
margin: 16px auto;
border-radius: 14px;
}
.header {
display: flex;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.title {
font-size: clamp(1.2rem, 4vw, 1.8rem);
font-weight: 700;
color: #0d1b52;
line-height: 1.2;
word-break: break-word;
}
.subtitle {
font-size: 12px;
color: #6b7280;
margin-top: 4px;
}
.alertState {
margin-bottom: 14px;
border-radius: 14px;
}
.timelineWrap {
display: grid;
gap: 12px;
}
.timelineHeader {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.chip {
background: #fafafa;
border-radius: 999px;
padding: 8px 12px;
display: flex;
gap: 8px;
align-items: center;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.chipLabel {
font-size: 12px;
color: #6b7280;
font-weight: 700;
}
.chipValue {
font-weight: 900;
}
.chipValue.strong {
color: #1677ff;
}
.simpleBox {
background: #fafafa;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 14px;
padding: 12px;
}
.k {
font-size: 12px;
color: #6b7280;
font-weight: 700;
}
.v {
font-weight: 900;
margin-top: 6px;
color: #111827;
}
.obs {
margin-top: 12px;
background: #fff;
border-radius: 12px;
padding: 10px;
border: 1px dashed rgba(22, 119, 255, 0.35);
}
.obsTitle {
font-size: 12px;
color: #6b7280;
font-weight: 800;
}
.obsText {
font-weight: 900;
margin-top: 4px;
}
/* Caja “no participa” */
.noBox {
border-radius: 14px;
padding: 14px;
background: rgba(107, 114, 128, 0.06);
border: 1px solid rgba(107, 114, 128, 0.18);
}
.noTitle {
font-weight: 900;
color: #111827;
margin-bottom: 6px;
}
.noDesc {
font-size: 12px;
color: #6b7280;
}
@media (max-width: 768px) {
.card {
margin: 0;
border-radius: 0;
}
}
</style>

@ -1,10 +1,10 @@
<template>
<div class="title">Mis procesos de admisión</div>
<div class="title">Mis procesos de admisión anteriores</div>
<a-card class="card" :bordered="true">
<template #title>
<div class="header">
<div class="headerLeft">
<div class="subtitle">Resultados registrados por DNI</div>
</div>
@ -22,21 +22,15 @@
</template>
<a-spin :spinning="loading">
<div class="tools">
<div class="counter">
<span class="counterLabel">Total</span>
<span class="counterValue">{{ procesosFiltrados.length }}</span>
</div>
<a-input
v-model:value="search"
allow-clear
placeholder="Buscar por nombre de proceso…"
class="search"
/>
</div>
<!-- Desktop -->
<div class="tableWrap desktopOnly">
<a-table
:dataSource="procesosFiltrados"
@ -46,10 +40,8 @@
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'nombre'">
<div class="nombre">{{ record.nombre || "-" }}</div>
<div class="meta">ID: {{ record.id }}</div>
<template v-if="column.key === 'titulo'">
<div class="nombre">{{ record.titulo || "-" }}</div>
</template>
<template v-else-if="column.key === 'puntaje'">
@ -68,7 +60,6 @@
Ver detalle
</a-button>
</template>
</template>
<template #emptyText>
@ -77,16 +68,12 @@
</a-table>
</div>
<!-- Mobile -->
<div class="cards mobileOnly">
<template v-if="procesosFiltrados.length">
<div
v-for="p in procesosFiltrados"
:key="p.id"
class="itemCard"
>
<div v-for="p in procesosFiltrados" :key="p.id" class="itemCard">
<div class="itemTop">
<div class="itemTitle">{{ p.nombre || "-" }}</div>
<div class="itemTitle">{{ p.titulo || "-" }}</div>
<span class="statusPill" :class="statusClass(p.apto)">
{{ aptoTexto(p.apto) }}
</span>
@ -99,8 +86,8 @@
</div>
<div class="kv">
<div class="k">ID</div>
<div class="v">{{ p.id }}</div>
<div class="k">Proceso:</div>
<div class="v">{{ p.titulo }}</div>
</div>
</div>
@ -119,7 +106,6 @@
<a-empty v-else description="No se encontraron procesos" />
</div>
</a-spin>
</a-card>
</template>
@ -134,7 +120,7 @@ const loading = ref(false);
const search = ref("");
const columns = [
{ title: "Proceso", dataIndex: "nombre", key: "nombre", width: 420 },
{ title: "Proceso", dataIndex: "titulo", key: "titulo", width: 420 },
{ title: "Puntaje", dataIndex: "puntaje", key: "puntaje", width: 140 },
{ title: "Estado", dataIndex: "apto", key: "apto", width: 160 },
{ title: "Acciones", key: "acciones", width: 160 },
@ -147,37 +133,42 @@ const obtenerProcesos = async () => {
if (data?.success) {
procesos.value = Array.isArray(data.data) ? data.data : [];
} else {
message.error("No se pudieron obtener los procesos");
message.error(data?.message || "No se pudieron obtener los procesos");
procesos.value = [];
}
} catch (e) {
message.error(e.response?.data?.message || "Error al cargar procesos");
procesos.value = [];
} finally {
loading.value = false;
}
};
const aptoTexto = (apto) => {
if (apto == 1) return "APTO";
if (apto == 0) return "NO APTO";
return "-";
if (apto === "SI") return "APTO";
if (apto === "NO") return "NO APTO";
return apto ?? "-";
};
const statusClass = (apto) => {
if (apto == 1) return "ok";
if (apto == 0) return "bad";
if (apto === "SI") return "ok";
if (apto === "NO") return "bad";
return "neutral";
};
const procesosFiltrados = computed(() => {
const q = search.value.trim().toLowerCase();
if (!q) return procesos.value;
return procesos.value.filter((p) =>
String(p.nombre || "").toLowerCase().includes(q)
String(p.titulo || "").toLowerCase().includes(q)
);
});
const verDetalle = (record) => {
message.info(`Proceso: ${record.nombre} | Puntaje: ${record.puntaje ?? "-"}`);
message.info(
`Proceso: ${record.titulo || "-"} | Puntaje: ${record.puntaje ?? "-"} | Estado: ${aptoTexto(record.apto)}`
);
};
onMounted(() => {
@ -186,8 +177,6 @@ onMounted(() => {
</script>
<style scoped>
.card {
width: 100%;
max-width: 1100px;
@ -195,7 +184,6 @@ onMounted(() => {
border-radius: 14px;
}
.header {
display: flex;
justify-content: space-between;
@ -241,7 +229,6 @@ onMounted(() => {
border-radius: 12px;
}
.tableWrap {
width: 100%;
overflow-x: auto;
@ -261,7 +248,6 @@ onMounted(() => {
color: #6b7280;
}
.statusPill {
padding: 4px 10px;
border-radius: 999px;
@ -271,12 +257,17 @@ onMounted(() => {
}
.statusPill.ok {
background: rgba(22,119,255,.1);
border: 1px solid rgba(22,119,255,.3);
background: rgba(22, 119, 255, 0.1);
border: 1px solid rgba(22, 119, 255, 0.3);
}
.statusPill.bad {
background: rgba(0,0,0,.04);
background: rgba(0, 0, 0, 0.04);
}
.statusPill.neutral {
background: rgba(107, 114, 128, 0.08);
border: 1px solid rgba(107, 114, 128, 0.18);
}
.cards {
@ -285,7 +276,7 @@ onMounted(() => {
}
.itemCard {
border: 1px solid rgba(0,0,0,.08);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 14px;
padding: 12px;
}
@ -294,6 +285,7 @@ onMounted(() => {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
}
.itemTitle {
@ -333,17 +325,24 @@ onMounted(() => {
margin-top: 12px;
}
.desktopOnly { display: block; }
.mobileOnly { display: none; }
.desktopOnly {
display: block;
}
.mobileOnly {
display: none;
}
@media (max-width: 768px) {
.tools {
grid-template-columns: 1fr;
}
.desktopOnly { display: none; }
.mobileOnly { display: block; }
.desktopOnly {
display: none;
}
.mobileOnly {
display: block;
}
.header {
flex-direction: column;
@ -356,5 +355,4 @@ onMounted(() => {
border-radius: 0;
}
}
</style>
</style>

@ -118,20 +118,20 @@
<span>Test</span>
</a-menu-item>
<a-menu-item key="documentos">
<!-- <a-menu-item key="documentos">
<FolderOutlined />
<span>Documentos</span>
</a-menu-item>
</a-menu-item> -->
<a-menu-item key="mis-procesos">
<FolderOutlined />
<span>Mis Procesos</span>
</a-menu-item>
<a-menu-item key="pagos">
<!-- <a-menu-item key="pagos">
<DollarOutlined />
<span>Pagos</span>
<a-tag v-if="!sidebarCollapsed" color="green" class="menu-tag">Al día</a-tag>
</a-menu-item>
</a-menu-item> -->
<a-menu-divider v-if="!sidebarCollapsed" />
@ -144,12 +144,12 @@
<span>Estado del Proceso</span>
</a-menu-item>
<a-menu-item key="resultados">
<!-- <a-menu-item key="resultados">
<FileDoneOutlined />
<span>Resultados</span>
</a-menu-item>
</a-menu-item> -->
<a-menu-divider v-if="!sidebarCollapsed" />
<!-- <a-menu-divider v-if="!sidebarCollapsed" />
<div class="menu-section" v-if="!sidebarCollapsed">
<div class="section-label">Configuración</div>
@ -158,7 +158,7 @@
<a-menu-item key="configuracion">
<SettingOutlined />
<span>Configuración</span>
</a-menu-item>
</a-menu-item> -->
</a-menu>
</div>
@ -256,7 +256,7 @@ const handleMenuSelect = ({ key }) => {
'documentos': { name: 'DocumentosPostulante' },
'pagos': { name: 'PanelPagos' },
'mis-procesos': { name: 'PanelProcesos' },
'seguimiento': { name: 'SeguimientoPostulante' },
'seguimiento': { name: 'AvanceProceso' },
'resultados': { name: 'ResultadosPostulante' },
'configuracion': { name: 'ConfiguracionPostulante' }
}

@ -467,10 +467,71 @@ const iniciarSesionExamen = async () => {
cargandoInicio.value = false;
}
};
const onKeyDownAntiCheat = (e) => {
const key = (e.key || "").toLowerCase();
// F12
if (e.key === "F12") {
e.preventDefault();
e.stopPropagation();
message.warning("Acción no permitida durante el examen.");
return;
}
// Ctrl+Shift+I / J / C (DevTools)
if (e.ctrlKey && e.shiftKey && ["i", "j", "c"].includes(key)) {
e.preventDefault();
e.stopPropagation();
message.warning("Acción no permitida durante el examen.");
return;
}
// Ctrl+U (view source)
if (e.ctrlKey && key === "u") {
e.preventDefault();
e.stopPropagation();
message.warning("Acción no permitida durante el examen.");
return;
}
// Ctrl+S / Ctrl+P (guardar/imprimir)
if (e.ctrlKey && (key === "s" || key === "p")) {
e.preventDefault();
e.stopPropagation();
message.warning("Acción no permitida durante el examen.");
return;
}
};
const onContextMenuAntiCheat = (e) => {
e.preventDefault();
message.warning("Acción no permitida durante el examen.");
};
const onCopyCutAntiCheat = (e) => {
e.preventDefault();
message.warning("Copiar/Cortar deshabilitado durante el examen.");
};
onMounted(iniciarSesionExamen);
onMounted(() => {
// anti-copia
window.addEventListener("keydown", onKeyDownAntiCheat, { capture: true });
window.addEventListener("contextmenu", onContextMenuAntiCheat);
window.addEventListener("copy", onCopyCutAntiCheat);
window.addEventListener("cut", onCopyCutAntiCheat);
// iniciar examen
iniciarSesionExamen();
});
onBeforeUnmount(() => {
// anti-copia
window.removeEventListener("keydown", onKeyDownAntiCheat, { capture: true });
window.removeEventListener("contextmenu", onContextMenuAntiCheat);
window.removeEventListener("copy", onCopyCutAntiCheat);
window.removeEventListener("cut", onCopyCutAntiCheat);
// timer
if (timerIntervalId) clearInterval(timerIntervalId);
});
</script>
@ -483,7 +544,20 @@ onBeforeUnmount(() => {
background: #f5f7fb;
min-height: 100vh;
}
/* Bloquea selección */
.exam-page {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* PERO permite seleccionar dentro de inputs/textarea */
:deep(input),
:deep(textarea) {
-webkit-user-select: text;
-ms-user-select: text;
user-select: text;
}
/* HEADER */
.top-card {
border-radius: 16px;
@ -763,7 +837,30 @@ onBeforeUnmount(() => {
.optRow:has(:deep(.ant-radio-checked)) {
background: rgba(37, 99, 235, 0.06);
}
/* Dentro de las opciones, fuerza a MarkdownLatex a no romper línea */
.optTextInline :deep(p),
.optTextInline :deep(div) {
display: inline;
margin: 0;
}
/* Si KaTeX display te rompe línea, lo “inlinea” dentro de opciones */
.optTextInline :deep(.katex-display) {
display: inline-block;
margin: 0;
padding: 0;
}
/* La parte del slot del radio (el span que envuelve tu contenido) */
.optRow :deep(.ant-radio + span) {
display: flex;
gap: 10px;
align-items: flex-start;
width: 100%;
}
/* Evita márgenes raros dentro de opciones */
.optTextInline :deep(.katex) {
line-height: 1.2;
}
/* hover suave */
.optRow:hover {
background: rgba(15, 23, 42, 0.03);

Loading…
Cancel
Save