ultimo_cambio

main
Elmer Yujra Condori 2 months ago
parent eb62405353
commit f400639c86

@ -340,4 +340,78 @@ public function obtenerAvanceProcesoPostulante(Request $request, $idProceso)
}
}
public function miObservacion(Request $request)
{
$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://test-admision.unap.edu.pe/service_observados/api/v1/observaciones/dni/{$dni}";
try {
$response = Http::timeout(15)->get($url);
if (!$response->successful()) {
return response()->json([
'success' => false,
'message' => 'No se pudo consultar observaciones',
'status_http' => $response->status(),
], 502);
}
$payload = $response->json();
// si no viene, asumimos false
$esObservado = (bool)($payload['es_observado'] ?? false);
// opcional: traer una observación "principal"
$detalle = null;
if (!empty($payload['observados']) && is_array($payload['observados'])) {
$o = $payload['observados'][0];
$detalle = [
'tipo_observacion' => $o['tipo_observacion'] ?? null,
'categoria' => $o['categoria'] ?? null,
'observaciones' => $o['observaciones'] ?? null,
'fecha_sancion' => $o['fecha_sancion'] ?? null,
'fecha_fin' => $o['fecha_fin'] ?? null,
];
}
return response()->json([
'success' => true,
'dni' => $dni,
'es_observado' => $esObservado,
'mensaje' => $esObservado
? 'Usted tiene una observación. Acérquese a la Dirección de Admisión para regularizar su situación.'
: 'Usted está apto para postular en este proceso.',
'detalle' => $detalle, // si no lo quieres, bórralo
]);
} catch (\Throwable $e) {
Log::error('Error consultando observaciones del postulante', [
'dni' => $dni,
'error' => $e->getMessage()
]);
return response()->json([
'success' => false,
'message' => 'Error interno consultando observaciones'
], 500);
}
}
}

@ -226,4 +226,6 @@ Route::middleware('auth:sanctum')->prefix('admin')->group(function () {
Route::delete('/comunicados/{id}', [ComunicadoController::class, 'destroy']);
Route::patch('/comunicados/{id}/toggle-activo', [ComunicadoController::class, 'toggleActivo']);
Route::delete('/comunicados/imagenes/{imagenId}', [ComunicadoController::class, 'destroyImagen']);
});
});
Route::middleware('auth:sanctum')->get('/postulante/observacion', [PostulanteAuthController::class, 'miObservacion']);

@ -112,6 +112,25 @@ export const useAuthStore = defineStore('auth', () => {
return false
}
const getObservacion = async () => {
try {
loading.value = true
error.value = null
const response = await api.get('/postulante/observacion', {
headers: { Authorization: `Bearer ${token.value}` }
})
return response.data
} catch (err) {
error.value = err.response?.data?.message || 'Error consultando observación'
return { success: false, message: error.value }
} finally {
loading.value = false
}
}
return {
token,
@ -128,6 +147,7 @@ export const useAuthStore = defineStore('auth', () => {
register,
login,
logout,
checkAuth
checkAuth,
getObservacion,
}
})

@ -6,7 +6,7 @@
<div class="header">
<div class="headerLeft">
<div class="subtitle">
Proceso 31: <b>Examen General 2026-I</b>
Proceso: <b>Examen General 2026-I</b>
</div>
</div>
@ -124,13 +124,24 @@ const normalizar = (txt) =>
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"))
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.";
}
// Registro + foto + huella (1 solo paso)
if (st.includes("registro") || st.includes("huella") || st.includes("foto") || st.includes("biometr")) {
return "Estás en inscripción final (registro, foto y huella). Completa lo solicitado para obtener tu constancia.";
}
// Constancia generada -> recién aquí mostrar examen
if (st.includes("constancia")) {
return "Ya generaste tu constancia. Prepárate para rendir el examen el 14 de febrero. Debes estar antes de las 9:30 a. m.; un minuto tarde y no podrás ingresar.";
}
return "Sigue revisando esta sección para ver el siguiente paso.";
});
onMounted(() => {

@ -8,6 +8,24 @@
<div class="hello">Bienvenido, {{ authStore.userName }}</div>
<div class="sub">DNI: {{ authStore.userDni || "No registrado" }}</div>
</div>
<!-- Estado observado (DERECHA) -->
<div class="topbarRight" v-if="esObservado !== null">
<div
class="statusPill"
:class="esObservado ? 'statusBad' : 'statusOk'"
:title="mensaje"
>
<span class="statusDot" />
<span class="statusText">
{{ esObservado ? "Observado" : "Apto" }}
</span>
</div>
<div class="statusMsg" :class="esObservado ? 'msgBad' : 'msgOk'">
{{ mensaje }}
</div>
</div>
</div>
<a-divider class="softDivider" />
@ -30,7 +48,7 @@
<div class="bulletRow">
<span class="dot" />
<span>
Si tu proceso pide secuencia, también puedes usar la del pago de tu
Si el test pide secuencia, también puedes usar la del pago de tu
<b>Carpeta de Postulante</b> (no pagas extra por este test).
</span>
</div>
@ -95,7 +113,7 @@
type="primary"
block
class="actionBtnPrimary"
:disabled="!record.link_preinscripcion"
:disabled="!record.link_preinscripcion || esObservado === true"
@click="onApply(record)"
>
Postular
@ -118,7 +136,7 @@
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { Modal, message } from "ant-design-vue";
import { Modal } from "ant-design-vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../../store/postulanteStore";
import axios from "../../axiosPostulante";
@ -127,6 +145,10 @@ const router = useRouter();
const authStore = useAuthStore();
const loading = ref(false);
const canApply = computed(() => esObservado.value === false);
// estado observado
const esObservado = ref(null);
const mensaje = ref("");
const state = reactive({
test: { hasAssigned: true },
@ -189,6 +211,18 @@ function onApply(process) {
window.open(process.link_preinscripcion, "_blank", "noopener,noreferrer");
}
onMounted(async () => {
// Observación (derecha)
const r = await authStore.getObservacion();
if (r?.success) {
esObservado.value = r.es_observado;
mensaje.value = r.mensaje;
} else {
esObservado.value = null;
mensaje.value = "";
}
});
onMounted(fetchDashboard);
</script>
@ -219,6 +253,13 @@ onMounted(fetchDashboard);
align-items: flex-start;
}
.topbarRight {
display: grid;
gap: 8px;
justify-items: end;
text-align: right;
}
.hello {
font-size: 28px;
font-weight: 900;
@ -233,6 +274,48 @@ onMounted(fetchDashboard);
color: #64748b;
}
/* estado */
.statusPill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 999px;
font-weight: 900;
font-size: 12px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: rgba(15, 23, 42, 0.03);
}
.statusDot {
width: 10px;
height: 10px;
border-radius: 999px;
background: currentColor;
}
.statusOk {
color: #16a34a;
border-color: rgba(22, 163, 74, 0.25);
background: rgba(22, 163, 74, 0.08);
}
.statusBad {
color: #dc2626;
border-color: rgba(220, 38, 38, 0.25);
background: rgba(220, 38, 38, 0.08);
}
.statusMsg {
max-width: 340px;
font-size: 12px;
color: #64748b;
line-height: 1.35;
}
.msgOk { color: #166534; }
.msgBad { color: #7f1d1d; }
/* ====== TEST CARD ====== */
.testBox {
border: 1px solid rgba(15, 23, 42, 0.08);
@ -382,6 +465,16 @@ onMounted(fetchDashboard);
.testHeadRight {
justify-items: stretch;
}
.topbarRight {
width: 100%;
justify-items: start;
text-align: left;
}
.statusMsg {
max-width: 100%;
}
}
@media (max-width: 768px) {
@ -405,7 +498,7 @@ onMounted(fetchDashboard);
font-size: 22px;
}
/* card full width en móvil (pro) */
/* card full width en móvil */
.testBox,
.tableWrap {
border-radius: 0;

@ -4,10 +4,9 @@
<a-col :xs="22" :sm="20" :md="20" :lg="16" :xl="14">
<div class="auth-shell">
<a-row :gutter="[0, 0]" class="auth-layout">
<!-- FORM -->
<a-col :xs="24" :md="12" class="auth-pane auth-pane-form">
<div class="pane-inner">
<div class="brand">
<div class="brand-mark">
<img
@ -51,7 +50,15 @@
>
<!-- DNI -->
<a-form-item v-if="isRegister" label="DNI" name="dni">
<a-input v-model:value="formState.dni" size="large" placeholder="Ingrese su DNI">
<a-input
v-model:value="formState.dni"
size="large"
placeholder="Ingrese su DNI"
inputmode="numeric"
:maxlength="8"
autocomplete="off"
@input="onDniInput"
>
<template #prefix><IdcardOutlined /></template>
</a-input>
</a-form-item>
@ -61,30 +68,45 @@
v-model:value="formState.name"
size="large"
placeholder="Ingrese su nombre completo"
autocomplete="name"
>
<template #prefix><UserOutlined /></template>
</a-input>
</a-form-item>
<a-form-item label="Correo electrónico" name="email">
<a-input v-model:value="formState.email" size="large" placeholder="correo@ejemplo.com">
<a-input
v-model:value="formState.email"
size="large"
placeholder="correo@ejemplo.com"
autocomplete="username"
>
<template #prefix><MailOutlined /></template>
</a-input>
</a-form-item>
<!-- PASSWORD -->
<a-form-item label="Contraseña" name="password">
<a-input-password
v-model:value="formState.password"
size="large"
placeholder="Ingrese su contraseña"
:autocomplete="isRegister ? 'new-password' : 'current-password'"
>
<template #prefix><LockOutlined /></template>
</a-input-password>
<!-- Solo en registro: requisitos (sin títulos/alertas/tips) -->
<ul v-if="isRegister" class="pwd-req">
<li :style="reqStyle(hasMinLength)">Mínimo 8 caracteres</li>
<li :style="reqStyle(hasUpper)">Al menos 1 mayúscula (A-Z)</li>
<li :style="reqStyle(hasLower)">Al menos 1 minúscula (a-z)</li>
<li :style="reqStyle(hasNumber)">Al menos 1 número (0-9)</li>
<li :style="reqStyle(hasSpecial)">Al menos 1 símbolo (@$!%*?&)</li>
</ul>
</a-form-item>
<!-- CONFIRM PASSWORD -->
<a-form-item
v-if="isRegister"
label="Confirmar contraseña"
@ -94,26 +116,17 @@
v-model:value="formState.password_confirmation"
size="large"
placeholder="Repita su contraseña"
autocomplete="new-password"
>
<template #prefix><LockOutlined /></template>
</a-input-password>
</a-form-item>
<a-row
v-if="!isRegister"
justify="space-between"
align="middle"
class="form-row"
>
<!-- ROW (LOGIN) -->
<a-row v-if="!isRegister" justify="space-between" align="middle" class="form-row">
<a-checkbox v-model:checked="rememberMe">Recordarme</a-checkbox>
<a-button
type="link"
size="small"
class="link-muted"
@click="handleForgotPassword"
>
<a-button type="link" size="small" class="link-muted" @click="handleForgotPassword">
¿Olvidó su contraseña?
</a-button>
</a-row>
@ -137,68 +150,57 @@
</div>
</a-col>
<a-col :xs="24" :md="12" class="auth-pane auth-pane-info">
<div class="pane-inner pane-inner-info">
<div class="info-top">
<a-tag class="info-tag">Universidad Nacional del Altiplano Puno</a-tag>
<a-typography-title :level="3" style="margin: 8px 0 0">
{{ isRegister ? "Registro de Postulante" : "Portal del Postulante" }}
</a-typography-title>
<a-typography-text type="secondary">
{{
isRegister
? "Crea tu cuenta para participar en el proceso de admisión y acceder a todos los servicios del portal."
: "Ingresa al portal para gestionar tu inscripción, revisar procesos disponibles y rendir un test de referencia."
}}
</a-typography-text>
</div>
<!-- INFO -->
<a-col :xs="24" :md="12" class="auth-pane auth-pane-info">
<div class="pane-inner pane-inner-info">
<div class="info-top">
<a-tag class="info-tag">Universidad Nacional del Altiplano Puno</a-tag>
<div class="info-section">
<div class="info-section-title">
{{ isRegister ? "Al registrarte podrás" : "Al ingresar podrás" }}
</div>
<div class="info-list">
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Rendir un test de referencia</b>.</span>
</div>
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Ver procesos disponibles</b> según tu modalidad.</span>
</div>
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Consultar tu estado</b> de inscripción y seguimiento del proceso.</span>
</div>
<!-- <div class="info-item">
<span class="info-bullet"></span>
<span><b>Ver tu resultado detallado</b> por cursos.</span>
</div> -->
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Revisar comunicados oficiales</b> del proceso de admisión.</span>
</div>
</div>
</div>
<a-typography-title :level="3" style="margin: 8px 0 0">
{{ isRegister ? "Registro de Postulante" : "Portal del Postulante" }}
</a-typography-title>
<div class="info-foot">
<a-typography-text type="secondary">
Plataforma oficial de admisión Soporte en horario institucional
</a-typography-text>
</div>
<a-typography-text type="secondary">
{{
isRegister
? "Crea tu cuenta para participar en el proceso de admisión y acceder a los servicios del portal."
: "Ingresa al portal para ver tu estado de inscripción, revisar procesos disponibles y rendir un test de referencia."
}}
</a-typography-text>
</div>
<div class="info-section">
<div class="info-section-title">
{{ isRegister ? "Al registrarte podrás" : "Al ingresar podrás" }}
</div>
</a-col>
<div class="info-list">
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Rendir un test de referencia</b>.</span>
</div>
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Ver procesos disponibles</b>.</span>
</div>
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Consultar tu estado</b> de inscripción y seguimiento del proceso.</span>
</div>
<div class="info-item">
<span class="info-bullet"></span>
<span><b>Revisar comunicados oficiales</b> del proceso de admisión.</span>
</div>
</div>
</div>
<div class="info-foot">
<a-typography-text type="secondary">
Plataforma oficial de admisión Soporte en horario institucional
</a-typography-text>
</div>
</div>
</a-col>
</a-row>
</div>
</a-col>
@ -221,7 +223,6 @@ const isRegister = ref(false);
const rememberMe = ref(false);
const loading = ref(false);
const logoSrc = "/logotiny.png";
const logoError = ref(false);
@ -243,6 +244,17 @@ watch(isRegister, () => {
formRef.value?.clearValidate();
});
const onDniInput = () => {
formState.dni = (formState.dni || "").replace(/\D/g, "").slice(0, 8);
};
// Requisitos visuales (mismos del backend)
const hasMinLength = computed(() => (formState.password || "").length >= 8);
const hasUpper = computed(() => /[A-Z]/.test(formState.password || ""));
const hasLower = computed(() => /[a-z]/.test(formState.password || ""));
const hasNumber = computed(() => /\d/.test(formState.password || ""));
const hasSpecial = computed(() => /[@$!%*?&]/.test(formState.password || ""));
const rules = computed(() => ({
dni: [
{ required: isRegister.value, message: "Ingrese su DNI", trigger: "blur" },
@ -258,20 +270,36 @@ const rules = computed(() => ({
],
password: [
{ required: true, message: "Ingrese su contraseña", trigger: "blur" },
{ min: 6, message: "La contraseña debe tener al menos 6 caracteres", trigger: "blur" },
],
password_confirmation: [
{ required: isRegister.value, message: "Confirme su contraseña", trigger: "blur" },
{
// Solo en registro validamos composición; en login no molestamos.
validator: (rule, value) => {
if (!isRegister.value) return Promise.resolve();
if (!value) return Promise.reject("Confirme su contraseña");
if (value !== formState.password) return Promise.reject("Las contraseñas no coinciden");
return Promise.resolve();
const v = value || "";
const ok =
v.length >= 8 &&
/[A-Z]/.test(v) &&
/[a-z]/.test(v) &&
/\d/.test(v) &&
/[@$!%*?&]/.test(v);
return ok ? Promise.resolve() : Promise.reject("Contraseña inválida");
},
trigger: "blur",
},
],
password_confirmation: [
{ required: isRegister.value, message: "Confirme su contraseña", trigger: "blur" },
{
validator: (rule, value) => {
if (!isRegister.value) return Promise.resolve();
if (!value) return Promise.resolve(); // el required ya se encarga
if (value !== formState.password) return Promise.reject("Las contraseñas no coinciden");
return Promise.resolve();
},
trigger: "blur",
},
],
}));
const showNotification = (type, message, description = "") => {
@ -288,13 +316,18 @@ const handleForgotPassword = () => {
showNotification("info", "Recuperación de contraseña", "Por favor, contacte al administrador del sistema");
};
const reqStyle = (ok) => ({
color: ok ? "#16a34a" : "#6b7280",
fontWeight: ok ? "700" : "500",
});
const handleSubmit = async () => {
loading.value = true;
try {
if (isRegister.value) {
const result = await authStore.register({ ...formState });
if (result.success) {
showNotification("success", "¡Registro exitoso!", "Tu cuenta ha sido creada correctamente");
showNotification("success", "¡Registro exitoso!");
toggleMode();
} else {
showNotification("error", "Error en registro", result.error);
@ -377,10 +410,6 @@ checkExistingAuth();
gap: 14px;
}
.pane-inner-info {
justify-content: space-between;
}
/* Branding */
.brand {
display: flex;
@ -426,10 +455,6 @@ checkExistingAuth();
font-size: 0.95rem;
}
.auth-header {
text-align: left;
}
.auth-divider {
margin: 6px 0 14px;
}
@ -474,29 +499,15 @@ checkExistingAuth();
font-weight: 800;
}
.info-top {
text-align: left;
}
.info-tag {
border: 0;
background: color-mix(in srgb, var(--ant-colorPrimary) 14%, transparent);
background: rgba(22, 119, 255, 0.12);
color: var(--ant-colorPrimary, #1677ff);
font-weight: 800;
border-radius: 999px;
padding: 6px 12px;
}
.info-section {
margin-top: 8px;
}
.info-section-title {
font-weight: 800;
color: var(--ant-colorTextHeading, #111827);
margin: 6px 0 10px;
}
.info-list {
margin-top: 10px;
display: grid;
@ -525,6 +536,11 @@ checkExistingAuth();
border-top: 1px solid var(--ant-colorBorderSecondary, rgba(0, 0, 0, 0.06));
}
.pwd-req {
margin: 8px 0 0 16px;
font-size: 12px;
}
@media (max-width: 768px) {
.auth-pane {
padding: 22px;
@ -537,10 +553,4 @@ checkExistingAuth();
min-height: auto;
}
}
@supports not (color: color-mix(in srgb, white 50%, black)) {
.info-tag {
background: rgba(22, 119, 255, 0.12);
}
}
</style>

@ -27,7 +27,6 @@
<span class="counterLabel">Total</span>
<span class="counterValue">{{ procesosFiltrados.length }}</span>
</div>
</div>
<!-- Desktop -->
@ -63,7 +62,7 @@
</template>
<template #emptyText>
<a-empty description="No se encontraron procesos" />
<a-empty :description="emptyMessage" />
</template>
</a-table>
</div>
@ -104,7 +103,7 @@
</div>
</template>
<a-empty v-else description="No se encontraron procesos" />
<a-empty v-else :description="emptyMessage" />
</div>
</a-spin>
</a-card>
@ -126,6 +125,10 @@ const columns = [
{ title: "Acciones", key: "acciones", width: 160 },
];
const emptyMessage = computed(() => {
return "Aún no tienes procesos anteriores registrados. Si participaste recientemente, esta sección puede demorar en actualizarse.";
});
const obtenerProcesos = async () => {
loading.value = true;
try {
@ -225,10 +228,6 @@ onMounted(() => {
font-size: 18px;
}
.search {
border-radius: 12px;
}
.tableWrap {
width: 100%;
overflow-x: auto;

@ -124,7 +124,7 @@
</a-menu-item> -->
<a-menu-item key="mis-procesos">
<FolderOutlined />
<span>Mis Procesos</span>
<span>Mis procesos anteriores</span>
</a-menu-item>
<!-- <a-menu-item key="pagos">
@ -141,7 +141,7 @@
<a-menu-item key="seguimiento">
<LineChartOutlined />
<span>Estado del Proceso</span>
<span>Seguimiento de inscripción</span>
</a-menu-item>
<!-- <a-menu-item key="resultados">

@ -499,7 +499,7 @@ No se realiza ningún pago adicional.
/>
<a-form v-else ref="formRef" :model="formState" :rules="rules" layout="vertical">
<a-form-item label="Proceso" name="proceso_id" required>
<a-form-item label="Nombre" name="proceso_id" required>
<a-select
v-model:value="formState.proceso_id"
placeholder="Selecciona un proceso"

Loading…
Cancel
Save