You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

807 lines
20 KiB
Vue

2 months ago
<template>
<div class="examen-panel">
<!-- Header con información del examen -->
<a-card>
<div class="examen-header">
<div class="header-info">
<h2>{{ examenInfo.proceso || 'Proceso no disponible' }}</h2>
<p><strong>Área:</strong> {{ examenInfo.area || 'Área no disponible' }}</p>
<p><strong>Duración:</strong> {{ examenInfo.duracion }} minutos</p>
<p><strong>Intentos:</strong> {{ examenInfo.intentos }} / {{ examenInfo.intentos_maximos }}</p>
<p><strong>Tiempo restante:</strong></p>
</div>
2 months ago
2 months ago
<div class="timer">
2 months ago
<a-statistic-countdown
2 months ago
:value="timerValue"
@finish="finalizarExamenAutomaticamente"
format="HH:mm:ss"
/>
</div>
</div>
</a-card>
2 months ago
<!-- Loading inicial -->
<a-card v-if="cargandoInicio" style="margin-top: 16px;">
<a-skeleton active />
</a-card>
<!-- Pregunta actual (UNA por pantalla) -->
<a-card
v-else-if="preguntaActual"
2 months ago
class="pregunta-card"
style="margin-top: 16px;"
>
<template #title>
<div class="pregunta-header">
2 months ago
<span class="pregunta-numero">
Pregunta {{ indiceActual + 1 }} de {{ totalPreguntas }}
</span>
2 months ago
<span class="curso-tag">
2 months ago
<a-tag color="blue">{{ preguntaActual.curso }}</a-tag>
2 months ago
</span>
2 months ago
<a-tag :color="tagColorEstado(preguntaActual)">
{{ tagTextoEstado(preguntaActual) }}
2 months ago
</a-tag>
</div>
</template>
<!-- Enunciado -->
2 months ago
<div class="enunciado" v-html="preguntaActual.enunciado"></div>
2 months ago
<!-- Contenido adicional -->
2 months ago
<div
v-if="preguntaActual.extra && preguntaActual.extra !== preguntaActual.enunciado"
class="extra"
v-html="preguntaActual.extra"
></div>
<!-- Opciones múltiples (NO guarda en change) -->
<div class="opciones" v-if="preguntaActual.opciones && preguntaActual.opciones.length">
<a-radio-group
v-model:value="preguntaActual.respuestaSeleccionada"
:disabled="preguntaActual.estado === 'respondida'"
2 months ago
>
<a-space direction="vertical" style="width: 100%;">
2 months ago
<a-radio
v-for="opcion in preguntaActual.opcionesOrdenadas"
2 months ago
:key="opcion.key"
2 months ago
:value="opcion.key.toString()"
2 months ago
class="opcion-radio"
>
<span class="opcion-key">{{ getLetraOpcion(opcion.key) }}.</span>
<span v-html="opcion.texto" class="opcion-texto"></span>
</a-radio>
</a-space>
</a-radio-group>
2 months ago
<div v-if="preguntaActual.respuestaSeleccionada" class="seleccion-actual">
<a-alert
:message="`Ha seleccionado: ${getTextoOpcionSeleccionada(preguntaActual)}`"
2 months ago
type="info"
show-icon
style="margin-top: 12px;"
/>
</div>
</div>
2 months ago
<!-- Pregunta abierta (NO guarda en blur) -->
2 months ago
<div v-else class="pregunta-abierta">
<a-textarea
2 months ago
v-model:value="preguntaActual.respuestaTexto"
2 months ago
placeholder="Escriba su respuesta aquí..."
:rows="4"
2 months ago
:disabled="preguntaActual.estado === 'respondida'"
2 months ago
/>
</div>
2 months ago
<!-- Botones por pregunta -->
<div class="nav-preguntas">
<a-button :disabled="indiceActual === 0" @click="irAnterior">
Anterior
</a-button>
<div class="nav-derecha">
<a-button
type="primary"
:loading="guardando"
:disabled="preguntaActual.estado === 'respondida'"
@click="guardarYContinuar"
>
Guardar respuesta y continuar
</a-button>
<a-button
:disabled="indiceActual >= totalPreguntas - 1"
@click="irSiguiente"
>
Siguiente
</a-button>
</div>
</div>
<!-- Debug -->
2 months ago
<div v-if="debugMode" class="debug-info">
2 months ago
<a-alert
:message="`DEBUG | id: ${preguntaActual.id} | estado: ${preguntaActual.estado} | seleccion: ${preguntaActual.respuestaSeleccionada || ''}`"
2 months ago
type="warning"
show-icon
style="margin-top: 12px;"
/>
</div>
</a-card>
2 months ago
<!-- Si no hay pregunta -->
<a-card v-else style="margin-top: 16px;">
<a-alert
type="warning"
show-icon
message="No hay preguntas para mostrar"
description="Verifique que el examen tenga preguntas generadas."
/>
<div style="margin-top: 12px; text-align: right;">
<a-button @click="router.push({ name: 'DashboardPostulante' })">Volver</a-button>
</div>
</a-card>
2 months ago
<!-- Resumen y botones -->
<a-card style="margin-top: 24px;">
<div class="resumen-examen">
<h3>Resumen del Examen</h3>
2 months ago
<p><strong>Total preguntas:</strong> {{ totalPreguntas }}</p>
<p><strong>Respondidas (guardadas):</strong> {{ preguntasRespondidas }} de {{ totalPreguntas }}</p>
2 months ago
<p><strong>Progreso:</strong></p>
2 months ago
<a-progress
:percent="porcentajeCompletado"
:stroke-color="{
'0%': '#108ee9',
'100%': '#87d068',
}"
/>
2 months ago
</div>
2 months ago
2 months ago
<div class="action-buttons" style="margin-top: 24px; text-align: center;">
2 months ago
<a-button
type="primary"
2 months ago
size="large"
:loading="finalizando"
@click="finalizarExamen"
:disabled="!todasRespondidas"
>
2 months ago
{{
todasRespondidas
? 'Finalizar Examen'
: `Responda todas las preguntas (${totalPreguntas - preguntasRespondidas} pendientes)`
}}
2 months ago
</a-button>
2 months ago
<a-button
type="default"
2 months ago
size="large"
style="margin-left: 12px;"
@click="guardarYSalir"
>
Guardar y salir
</a-button>
</div>
</a-card>
</div>
</template>
<script setup>
2 months ago
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
2 months ago
import { useRoute, useRouter } from 'vue-router'
import { useExamenStore } from '../../store/examen.store'
import { message, Modal } from 'ant-design-vue'
const route = useRoute()
const router = useRouter()
const examenStore = useExamenStore()
const finalizando = ref(false)
2 months ago
const guardando = ref(false)
const debugMode = ref(false)
2 months ago
const timerValue = ref(null)
2 months ago
let timerIntervalId = null
const preguntasLocal = ref([])
const indiceActual = ref(0)
2 months ago
2 months ago
const initOnce = ref(false)
const cargandoInicio = ref(false)
/* ---------------------------
INFO EXAMEN
---------------------------- */
2 months ago
const examenInfo = computed(() => {
if (!examenStore.examenActual) {
return {
proceso: null,
area: null,
duracion: 60,
intentos: 0,
intentos_maximos: 3
}
}
return {
proceso: examenStore.examenActual?.proceso || 'Proceso no disponible',
area: examenStore.examenActual?.area || 'Área no disponible',
duracion: examenStore.examenActual?.duracion || 60,
intentos: examenStore.examenActual?.intentos || 0,
intentos_maximos: examenStore.examenActual?.intentos_maximos || 3
}
})
2 months ago
/* ---------------------------
TRANSFORMACIÓN (UNA VEZ)
---------------------------- */
const transformarPreguntas = (arr) => {
if (!Array.isArray(arr)) return []
2 months ago
2 months ago
return arr.map((pregunta) => {
2 months ago
let respuestaKey = null
2 months ago
2 months ago
if (pregunta.opciones && pregunta.respuesta) {
2 months ago
const opcionCorrecta = pregunta.opciones.find(op =>
op.texto === pregunta.respuesta ||
2 months ago
op.key.toString() === pregunta.respuesta.toString()
)
respuestaKey = opcionCorrecta ? opcionCorrecta.key : null
}
2 months ago
const opcionesOrdenadas = pregunta.opciones
? [...pregunta.opciones].sort((a, b) => a.key - b.key)
: []
2 months ago
return {
...pregunta,
2 months ago
respuestaKey,
2 months ago
opcionesOrdenadas,
2 months ago
// estado viene del backend: 'pendiente' o 'respondida'
2 months ago
respuestaSeleccionada: null,
2 months ago
respuestaTexto: ''
2 months ago
}
})
2 months ago
}
/* IMPORTANTE:
No reseteamos preguntasLocal si ya existe, para no perder respuestas locales.
*/
watch(
() => examenStore.preguntas,
(nuevas) => {
if (!Array.isArray(nuevas) || nuevas.length === 0) return
if (preguntasLocal.value.length === 0) {
preguntasLocal.value = transformarPreguntas(nuevas)
indiceActual.value = 0
}
},
{ immediate: true }
)
/* ---------------------------
COMPUTEDS
---------------------------- */
const totalPreguntas = computed(() => preguntasLocal.value.length)
const preguntaActual = computed(() => {
return preguntasLocal.value[indiceActual.value] || null
2 months ago
})
2 months ago
const preguntasRespondidas = computed(() => {
return preguntasLocal.value.filter(p => p.estado === 'respondida').length
})
const porcentajeCompletado = computed(() => {
if (totalPreguntas.value === 0) return 0
return Math.round((preguntasRespondidas.value / totalPreguntas.value) * 100)
})
const todasRespondidas = computed(() => {
return preguntasLocal.value.every(p => p.estado === 'respondida')
})
/* ---------------------------
HELPERS UI
---------------------------- */
2 months ago
const getLetraOpcion = (key) => {
const letras = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
return letras[key] || `Opción ${key}`
}
const getTextoOpcionSeleccionada = (pregunta) => {
2 months ago
if (!pregunta?.respuestaSeleccionada) return ''
2 months ago
const opcion = pregunta.opcionesOrdenadas.find(
op => op.key.toString() === pregunta.respuestaSeleccionada.toString()
)
return opcion ? opcion.texto : 'Opción no encontrada'
}
2 months ago
const tieneRespuestaLocal = (p) => {
const tieneOpciones = p?.opciones && p.opciones.length
if (tieneOpciones) return !!p.respuestaSeleccionada
return !!(p.respuestaTexto && p.respuestaTexto.trim())
}
2 months ago
2 months ago
const tagTextoEstado = (p) => {
if (p.estado === 'respondida') return 'Respondida'
if (tieneRespuestaLocal(p)) return 'Sin guardar'
return 'Pendiente'
}
2 months ago
2 months ago
const tagColorEstado = (p) => {
if (p.estado === 'respondida') return 'green'
if (tieneRespuestaLocal(p)) return 'gold'
return 'orange'
}
2 months ago
2 months ago
/* ---------------------------
NAVEGACIÓN
---------------------------- */
const irSiguiente = () => {
if (indiceActual.value < totalPreguntas.value - 1) indiceActual.value++
}
const irAnterior = () => {
if (indiceActual.value > 0) indiceActual.value--
}
/* ---------------------------
GUARDADO (BOTÓN)
---------------------------- */
const guardarRespuestaDePregunta = async (p) => {
if (!p) return { success: false, message: 'No hay pregunta' }
if (p.estado === 'respondida') return { success: true }
const tieneOpciones = p.opciones && p.opciones.length
if (tieneOpciones) {
if (!p.respuestaSeleccionada) return { success: false, message: 'Seleccione una opción' }
const respuestaString = p.respuestaSeleccionada.toString()
const opcionSeleccionada = p.opcionesOrdenadas.find(op => op.key.toString() === respuestaString)
2 months ago
const textoRespuesta = opcionSeleccionada ? opcionSeleccionada.texto : respuestaString
2 months ago
return await examenStore.responderPregunta(p.id, textoRespuesta)
2 months ago
}
2 months ago
if (!p.respuestaTexto || !p.respuestaTexto.trim()) {
return { success: false, message: 'Escriba una respuesta' }
}
return await examenStore.responderPregunta(p.id, p.respuestaTexto.trim())
2 months ago
}
2 months ago
const guardarYContinuar = async () => {
const p = preguntaActual.value
if (!p) return
2 months ago
try {
2 months ago
guardando.value = true
const result = await guardarRespuestaDePregunta(p)
if (!result?.success) {
message.error(result?.message || 'Error al guardar respuesta')
return
}
p.estado = 'respondida'
message.success('Respuesta guardada')
if (indiceActual.value < totalPreguntas.value - 1) {
indiceActual.value++
2 months ago
} else {
2 months ago
message.info('Ya estás en la última pregunta')
2 months ago
}
2 months ago
} catch (e) {
console.error(e)
2 months ago
message.error('Error al guardar respuesta')
2 months ago
} finally {
guardando.value = false
2 months ago
}
}
2 months ago
/* ---------------------------
FINALIZAR / GUARDAR Y SALIR
---------------------------- */
2 months ago
const finalizarExamen = async () => {
if (!todasRespondidas.value) {
2 months ago
message.warning(`Por favor responda todas las preguntas. ${totalPreguntas.value - preguntasRespondidas.value} pendientes.`)
2 months ago
return
}
Modal.confirm({
title: '¿Está seguro de finalizar el examen?',
content: 'Una vez finalizado no podrá modificar sus respuestas.',
okText: 'Sí, finalizar',
cancelText: 'Cancelar',
onOk: async () => {
try {
finalizando.value = true
const result = await examenStore.finalizarExamen(route.params.examenId)
2 months ago
2 months ago
if (result.success) {
message.success('Examen finalizado correctamente')
2 months ago
router.push({
// ⚠️ Ajusta el name si tu ruta se llama distinto
name: 'PanelResultados',
params: { examenId: route.params.examenId }
2 months ago
})
} else {
message.error(result.message || 'Error al finalizar examen')
}
} catch (error) {
message.error('Error al finalizar examen')
console.error(error)
} finally {
finalizando.value = false
}
}
})
}
const guardarYSalir = async () => {
2 months ago
const pendientes = preguntasLocal.value.filter(p => p.estado !== 'respondida' && tieneRespuestaLocal(p))
if (pendientes.length > 0) {
2 months ago
Modal.confirm({
title: 'Guardar respuestas pendientes',
2 months ago
content: `Tiene ${pendientes.length} respuesta(s) lista(s) pero no guardada(s). ¿Desea guardarlas antes de salir?`,
2 months ago
okText: 'Guardar y salir',
cancelText: 'Salir sin guardar',
onOk: async () => {
try {
2 months ago
for (const p of pendientes) {
const r = await guardarRespuestaDePregunta(p)
if (r?.success) p.estado = 'respondida'
2 months ago
}
message.success('Respuestas guardadas')
router.push({ name: 'DashboardPostulante' })
2 months ago
} catch (e) {
console.error(e)
2 months ago
message.error('Error al guardar respuestas')
}
},
onCancel: () => {
router.push({ name: 'DashboardPostulante' })
}
})
} else {
router.push({ name: 'DashboardPostulante' })
}
}
2 months ago
/* ---------------------------
TIMER
---------------------------- */
2 months ago
const finalizarExamenAutomaticamente = () => {
message.warning('Tiempo agotado. El examen se finalizará automáticamente.')
finalizarExamen()
}
const calcularTiempoRestante = () => {
2 months ago
// preferir hora_inicio del backend
2 months ago
if (examenStore.examenActual?.hora_inicio && examenInfo.value.duracion) {
const horaInicio = new Date(examenStore.examenActual.hora_inicio)
const duracionMs = examenInfo.value.duracion * 60 * 1000
const tiempoFinal = horaInicio.getTime() + duracionMs
timerValue.value = tiempoFinal
2 months ago
if (Date.now() > tiempoFinal) finalizarExamenAutomaticamente()
2 months ago
} else {
2 months ago
// fallback
2 months ago
timerValue.value = Date.now() + (examenInfo.value.duracion * 60 * 1000)
}
}
2 months ago
/* ---------------------------
INICIO EXAMEN (EJECUTAR 1 SOLA VEZ)
---------------------------- */
const iniciarSesionExamen = async () => {
if (initOnce.value) return
initOnce.value = true
const examenId = route.params.examenId
if (!examenId) {
message.error('No se encontró el ID del examen')
router.push({ name: 'DashboardPostulante' })
return
}
2 months ago
try {
2 months ago
cargandoInicio.value = true
// ✅ AQUÍ SÍ se ejecuta iniciarExamen con su examen_id
const r = await examenStore.iniciarExamen(examenId)
if (!r?.success) {
message.error(r?.message || 'No se pudo iniciar el examen')
router.push({ name: 'DashboardPostulante' })
return
}
// Inicializar preguntasLocal con lo que vino del backend
if (Array.isArray(examenStore.preguntas) && examenStore.preguntas.length > 0) {
preguntasLocal.value = transformarPreguntas(examenStore.preguntas)
indiceActual.value = 0
} else {
2 months ago
message.error('No se encontraron preguntas para este examen')
router.push({ name: 'DashboardPostulante' })
return
}
2 months ago
// Timer basado en hora_inicio real
2 months ago
calcularTiempoRestante()
2 months ago
if (timerIntervalId) clearInterval(timerIntervalId)
timerIntervalId = setInterval(calcularTiempoRestante, 30000)
} catch (e) {
console.error(e)
message.error('Error al iniciar el examen')
2 months ago
router.push({ name: 'DashboardPostulante' })
2 months ago
} finally {
cargandoInicio.value = false
2 months ago
}
2 months ago
}
onMounted(() => {
iniciarSesionExamen()
2 months ago
})
2 months ago
onBeforeUnmount(() => {
if (timerIntervalId) clearInterval(timerIntervalId)
})
2 months ago
</script>
<style scoped>
.examen-panel {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.examen-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 20px;
padding: 16px;
background: #f0f9ff;
border-radius: 8px;
border: 1px solid #d6e4ff;
}
.header-info h2 {
margin: 0 0 8px 0;
color: #1890ff;
font-size: 20px;
}
.header-info p {
margin: 4px 0;
color: #595959;
}
.timer {
text-align: right;
min-width: 180px;
}
.timer :deep(.ant-statistic-content) {
font-size: 28px;
font-weight: bold;
color: #fa541c;
}
.pregunta-card {
margin-bottom: 24px;
border: 1px solid #e8e8e8;
border-radius: 8px;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.08);
transition: box-shadow 0.3s;
}
.pregunta-card:hover {
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.12);
}
.pregunta-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.pregunta-numero {
font-weight: bold;
font-size: 18px;
color: #1890ff;
}
.curso-tag {
flex-grow: 1;
margin-left: 12px;
}
.enunciado {
font-size: 16px;
line-height: 1.7;
margin: 20px 0;
padding: 20px;
background: #fafafa;
border-radius: 8px;
border-left: 4px solid #1890ff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.extra {
font-size: 14px;
line-height: 1.6;
margin: 16px 0;
padding: 16px;
background: #fff7e6;
border-radius: 6px;
border: 1px solid #ffd591;
color: #d46b08;
}
.opciones {
margin-top: 24px;
}
.opcion-radio {
display: flex;
align-items: center;
padding: 16px 20px;
margin: 10px 0;
border: 2px solid #d9d9d9;
border-radius: 8px;
transition: all 0.3s;
min-height: 60px;
cursor: pointer;
}
.opcion-radio:hover {
border-color: #40a9ff;
background: #f0f9ff;
transform: translateY(-2px);
}
.opcion-radio:deep(.ant-radio) {
margin-right: 12px;
}
.opcion-key {
font-weight: bold;
color: #1890ff;
margin-right: 12px;
min-width: 24px;
font-size: 16px;
}
.opcion-texto {
flex: 1;
line-height: 1.6;
font-size: 15px;
}
.seleccion-actual {
margin-top: 16px;
}
.pregunta-abierta textarea {
margin-top: 16px;
font-size: 15px;
line-height: 1.6;
}
.resumen-examen {
padding: 16px;
}
.resumen-examen h3 {
margin-bottom: 16px;
color: #1890ff;
}
.resumen-examen p {
margin: 8px 0;
color: #595959;
}
.action-buttons {
padding: 24px 0;
border-top: 1px solid #f0f0f0;
margin-top: 32px;
display: flex;
justify-content: center;
gap: 12px;
}
2 months ago
.nav-preguntas {
display: flex;
gap: 12px;
margin-top: 20px;
justify-content: space-between;
flex-wrap: wrap;
}
.nav-derecha {
display: flex;
gap: 12px;
margin-left: auto;
}
2 months ago
:deep(.ant-card-head) {
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
:deep(.ant-card-body) {
padding: 24px;
}
/* Responsive */
@media (max-width: 768px) {
.examen-panel {
padding: 10px;
}
2 months ago
2 months ago
.examen-header {
flex-direction: column;
align-items: stretch;
}
2 months ago
2 months ago
.timer {
text-align: left;
margin-top: 16px;
}
2 months ago
2 months ago
.pregunta-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
2 months ago
2 months ago
.curso-tag {
margin-left: 0;
}
2 months ago
2 months ago
.enunciado {
padding: 16px;
font-size: 15px;
}
2 months ago
2 months ago
.opcion-radio {
padding: 12px 16px;
}
2 months ago
2 months ago
.action-buttons {
flex-direction: column;
gap: 16px;
}
2 months ago
2 months ago
.action-buttons button {
width: 100%;
}
2 months ago
.nav-derecha {
width: 100%;
margin-left: 0;
flex-direction: column;
}
.nav-derecha button {
width: 100%;
}
2 months ago
}
</style>