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.

507 lines
14 KiB
Vue

2 months ago
<!-- PanelResultados.vue (DISEÑO ALTERNATIVO: "Scoreboard" moderno, full responsive) -->
2 months ago
<template>
2 months ago
<div class="page alt">
<!-- Header minimal -->
<div class="head">
<div>
<div class="hTitle">Resultados del examen</div>
<div class="hSub">Resumen claro + detalle por curso. Todo responsivo.</div>
2 months ago
</div>
2 months ago
<!-- (opcional) acciones -->
<div class="headActions">
<a-space class="headSpace">
<a-button class="btnSoft" @click="volver" :disabled="cargando">Volver</a-button>
<a-button class="btnPrimary" type="primary" @click="recalcular" :loading="cargando">
Actualizar
</a-button>
</a-space>
2 months ago
</div>
2 months ago
</div>
<a-spin :spinning="cargando" tip="Cargando resultados...">
<template v-if="resultado">
<!-- TOP: Scoreboard -->
<a-row :gutter="[16, 16]">
<!-- Puntaje principal -->
<a-col :xs="24" :lg="10">
<a-card class="card scoreCard" :bordered="false">
<div class="scoreTop">
<div class="chipDone">
<span class="chipDot" />
Calificado
2 months ago
</div>
2 months ago
<div v-if="resultado.orden_merito != null" class="chipRank">
Orden: <b>#{{ resultado.orden_merito }}</b>
2 months ago
</div>
</div>
2 months ago
<div class="scoreMain">{{ fmt2(resultado.total_puntos) }}</div>
<div class="scoreLabel">Puntaje total</div>
<div class="bigProgress">
<a-progress :percent="notaPercent" />
<div class="bigProgressMeta">
<span>Nota equivalente</span>
<b>{{ fmt2(resultado.calificacion_sobre_20) }}/20</b>
</div>
2 months ago
</div>
2 months ago
<a-alert
class="advice"
show-icon
type="success"
message="Consejo"
:description="consejo"
/>
</a-card>
</a-col>
<!-- KPIs -->
<a-col :xs="24" :lg="14">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12" :md="8">
<a-card class="card kpi" :bordered="false">
<div class="kpiK">Correctas</div>
<div class="kpiV">{{ resultado.total_correctas }}</div>
<div class="kpiHint">Aciertos</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-card class="card kpi" :bordered="false">
<div class="kpiK">Incorrectas</div>
<div class="kpiV">{{ resultado.total_incorrectas }}</div>
<div class="kpiHint">Errores</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-card class="card kpi" :bordered="false">
<div class="kpiK">Blanco/Nulas</div>
<div class="kpiV">{{ resultado.total_nulas }}</div>
<div class="kpiHint">Sin respuesta</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="12">
<a-card class="card kpiWide" :bordered="false">
<div class="kpiWideRow">
<div>
<div class="kpiK">% Correctas</div>
<div class="kpiV">{{ fmt2(resultado.porcentaje_correctas) }}%</div>
</div>
<div class="miniCircle">
<a-progress type="circle" :percent="notaPercent" :width="96" />
<div class="miniCircleLabel">
<div class="miniCircleMain">{{ fmt2(resultado.calificacion_sobre_20) }}</div>
<div class="miniCircleSub">/20</div>
</div>
</div>
</div>
</a-card>
</a-col>
<a-col :xs="24" :sm="12" :md="12">
<a-card class="card noteCard" :bordered="false">
<div class="kpiK">Nota sobre 20</div>
<div class="kpiV">{{ fmt2(resultado.calificacion_sobre_20) }}/20</div>
<div class="kpiHint">Equivalencia según puntaje máximo del proceso</div>
</a-card>
</a-col>
</a-row>
</a-col>
</a-row>
<!-- DETALLE: tabla por curso -->
<a-row :gutter="[16, 16]" class="mt16">
<a-col :xs="24">
<a-card title="Desempeño por curso" class="card" :bordered="false">
<div class="tableWrap">
<a-table
:columns="columns"
:dataSource="rowsCursos"
:pagination="false"
size="middle"
rowKey="curso"
:scroll="{ x: 760 }"
class="modernTable"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'curso'">
<div class="courseCell">
<span class="courseDot"></span>
<div>
<div class="courseName">{{ record.curso }}</div>
<div class="courseMeta">Ratio: {{ record.ratio }}%</div>
</div>
</div>
</template>
<template v-else-if="column.dataIndex === 'correctas'">
<span class="pill good">{{ record.correctas }}</span>
</template>
<template v-else-if="column.dataIndex === 'incorrectas'">
<span class="pill bad">{{ record.incorrectas }}</span>
</template>
<template v-else-if="column.dataIndex === 'total'">
<span class="pill neutral">{{ record.total }}</span>
</template>
<template v-else-if="column.dataIndex === 'ratio'">
<a-progress :percent="record.ratio" />
</template>
</template>
</a-table>
</div>
</a-card>
</a-col>
</a-row>
</template>
<template v-else>
<a-empty description="No hay resultados disponibles" class="empty">
<a-button type="primary" class="btnPrimary" @click="recalcular" :loading="cargando">
Calcular resultados
</a-button>
</a-empty>
</template>
</a-spin>
2 months ago
</div>
</template>
<script setup>
2 months ago
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
2 months ago
import { message } from 'ant-design-vue'
2 months ago
import { useExamenStore } from '../../store/examen.store'
2 months ago
2 months ago
const route = useRoute()
2 months ago
const router = useRouter()
const examenStore = useExamenStore()
2 months ago
const cargando = ref(false)
2 months ago
2 months ago
const examenId = computed(() => Number(route.params.examenId))
const resultado = computed(() => examenStore.resultado)
2 months ago
2 months ago
const fmt2 = (n) => {
const x = Number(n ?? 0)
return x.toFixed(2)
}
2 months ago
2 months ago
const notaPercent = computed(() => {
const nota = Number(resultado.value?.calificacion_sobre_20 ?? 0)
const percent = (nota / 20) * 100
return Math.max(0, Math.min(100, Math.round(percent)))
2 months ago
})
2 months ago
const consejo = computed(() => {
const pct = Number(resultado.value?.porcentaje_correctas ?? 0)
if (pct >= 80) return '¡Excelente! Mantén el ritmo y refuerza los cursos donde tuviste menos margen.'
if (pct >= 60) return 'Vas bien. Enfócate en los cursos con menor ratio y repasa preguntas falladas.'
return 'No te preocupes: identifica 12 cursos clave, practica y vuelve a intentarlo con estrategia.'
2 months ago
})
2 months ago
const columns = [
{ title: 'Curso', dataIndex: 'curso', key: 'curso', width: 260 },
{ title: 'Correctas', dataIndex: 'correctas', key: 'correctas', width: 120 },
{ title: 'Incorrectas', dataIndex: 'incorrectas', key: 'incorrectas', width: 130 },
{ title: 'Total', dataIndex: 'total', key: 'total', width: 100 },
{ title: 'Progreso', dataIndex: 'ratio', key: 'ratio', width: 240 },
]
const rowsCursos = computed(() => {
const corr = resultado.value?.correctas_por_curso || {}
const inc = resultado.value?.incorrectas_por_curso || {}
const tot = resultado.value?.preguntas_totales_por_curso || {}
const keys = new Set([
...Object.keys(corr || {}),
...Object.keys(inc || {}),
...Object.keys(tot || {}),
])
const parseCorrectas = (val) => {
if (typeof val === 'string' && val.includes('de')) {
const [a] = val.split('de')
const x = Number(a.trim())
return Number.isFinite(x) ? x : 0
}
const x = Number(val ?? 0)
return Number.isFinite(x) ? x : 0
2 months ago
}
2 months ago
const parseTotal = (curso) => {
const x = Number(tot?.[curso] ?? 0)
return Number.isFinite(x) ? x : 0
2 months ago
}
2 months ago
const parseIncorrectas = (curso) => {
const x = Number(inc?.[curso] ?? 0)
return Number.isFinite(x) ? x : 0
2 months ago
}
2 months ago
const rows = Array.from(keys).map((k) => {
const correctas = parseCorrectas(corr?.[k] ?? 0)
const total = parseTotal(k) || (() => {
const val = corr?.[k]
if (typeof val === 'string' && val.includes('de')) {
const [, b] = val.split('de')
const y = Number(b.trim())
return Number.isFinite(y) ? y : 0
}
return 0
})()
const incorrectas = parseIncorrectas(k)
const ratio = total > 0 ? Math.round((correctas / total) * 100) : 0
return { curso: k, correctas, incorrectas, total, ratio }
})
rows.sort((a, b) => a.ratio - b.ratio)
return rows
})
2 months ago
2 months ago
const recalcular = async () => {
if (!examenId.value) return
cargando.value = true
try {
const r = await examenStore.calificarExamen(examenId.value)
if (r?.success) message.success('Resultados actualizados')
else message.error(r?.message || 'No se pudo calcular el resultado')
} catch (e) {
console.error(e)
message.error('Error al obtener resultados')
} finally {
cargando.value = false
2 months ago
}
}
2 months ago
const volver = () => router.back()
onMounted(async () => {
if (!resultado.value && examenId.value) {
await recalcular()
2 months ago
}
})
</script>
<style scoped>
2 months ago
/* Layout */
.page {
2 months ago
padding: 16px;
2 months ago
max-width: 1120px;
margin: 0 auto;
2 months ago
}
2 months ago
.mt16 { margin-top: 16px; }
2 months ago
2 months ago
/* Diseño alternativo: limpio, tipo "scoreboard" */
.alt {
background: transparent;
2 months ago
}
2 months ago
/* Header */
.head {
2 months ago
display: flex;
2 months ago
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
margin-bottom: 14px;
2 months ago
}
2 months ago
.hTitle {
margin: 0;
font-size: 1.85rem;
font-weight: 700;
color: #0d1b52;
line-height: 1.15;
2 months ago
}
2 months ago
.hSub {
2 months ago
margin-top: 4px;
2 months ago
font-size: 12.5px;
color: var(--ant-colorTextSecondary, #6b7280);
line-height: 1.35;
2 months ago
}
2 months ago
.headActions {
min-width: 260px;
display: flex;
justify-content: flex-end;
2 months ago
}
2 months ago
.btnSoft, .btnPrimary { border-radius: 12px; font-weight: 900; }
2 months ago
2 months ago
/* Cards */
.card {
border-radius: 18px;
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);
2 months ago
}
2 months ago
.card :deep(.ant-card-body) { padding: 16px; }
.scoreCard :deep(.ant-card-body) { padding: 18px; }
2 months ago
2 months ago
.scoreTop {
2 months ago
display: flex;
justify-content: space-between;
2 months ago
gap: 10px;
flex-wrap: wrap;
2 months ago
align-items: center;
2 months ago
margin-bottom: 10px;
2 months ago
}
2 months ago
.chipDone, .chipRank {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(22,119,255,.18);
background: rgba(22,119,255,.06);
font-weight: 900;
font-size: 12px;
color: #111827;
2 months ago
}
2 months ago
.chipDot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--ant-colorPrimary, #1677ff);
2 months ago
}
2 months ago
.scoreMain {
font-size: 44px;
font-weight: 950;
color: #111827;
line-height: 1;
2 months ago
}
2 months ago
.scoreLabel {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
font-weight: 800;
2 months ago
}
2 months ago
.bigProgress { margin-top: 12px; }
.bigProgressMeta {
margin-top: 6px;
2 months ago
display: flex;
2 months ago
justify-content: space-between;
gap: 10px;
font-size: 12px;
color: #6b7280;
2 months ago
}
2 months ago
.bigProgressMeta b { color: #111827; }
2 months ago
2 months ago
.advice { margin-top: 12px; border-radius: 14px; }
2 months ago
2 months ago
/* KPIs */
.kpi .kpiK, .kpiWide .kpiK, .noteCard .kpiK {
font-size: 12px;
color: #6b7280;
font-weight: 900;
2 months ago
}
2 months ago
.kpi .kpiV, .kpiWide .kpiV, .noteCard .kpiV {
margin-top: 6px;
font-size: 26px;
font-weight: 950;
color: #111827;
2 months ago
}
2 months ago
.kpiHint {
margin-top: 2px;
font-size: 12px;
color: #6b7280;
2 months ago
}
2 months ago
.kpiWideRow {
2 months ago
display: flex;
2 months ago
justify-content: space-between;
2 months ago
gap: 12px;
align-items: center;
2 months ago
flex-wrap: wrap;
2 months ago
}
2 months ago
.miniCircle {
position: relative;
display: inline-flex;
2 months ago
align-items: center;
justify-content: center;
}
2 months ago
.miniCircleLabel {
position: absolute;
text-align: center;
2 months ago
}
2 months ago
.miniCircleMain { font-weight: 950; font-size: 16px; color: #111827; line-height: 1; }
.miniCircleSub { font-size: 12px; color: #6b7280; }
2 months ago
2 months ago
/* Tabla */
.tableWrap {
width: 100%;
overflow-x: auto;
border-radius: 14px;
2 months ago
}
2 months ago
.tableWrap :deep(.ant-table-container) { overflow-x: auto; }
:deep(.modernTable .ant-table-thead > tr > th) {
background: rgba(22,119,255,.04);
font-weight: 900;
2 months ago
}
2 months ago
.courseCell {
2 months ago
display: flex;
2 months ago
gap: 10px;
align-items: flex-start;
}
.courseDot {
width: 10px;
height: 10px;
border-radius: 999px;
background: var(--ant-colorPrimary, #1677ff);
margin-top: 6px;
flex: 0 0 auto;
}
.courseName { font-weight: 900; color: #111827; line-height: 1.2; }
.courseMeta { margin-top: 2px; font-size: 12px; color: #6b7280; }
/* Pills numéricas (en vez de tags de color) */
.pill {
display: inline-flex;
align-items: center;
2 months ago
justify-content: center;
2 months ago
min-width: 44px;
height: 30px;
padding: 0 10px;
border-radius: 999px;
font-weight: 950;
2 months ago
font-size: 12px;
2 months ago
border: 1px solid rgba(0,0,0,.08);
background: rgba(0,0,0,.03);
color: #111827;
2 months ago
}
2 months ago
.pill.good { border-color: rgba(22,119,255,.25); background: rgba(22,119,255,.08); }
.pill.bad { border-color: rgba(0,0,0,.10); background: rgba(0,0,0,.04); }
.pill.neutral { opacity: .92; }
2 months ago
2 months ago
/* Empty */
.empty { margin-top: 12px; }
2 months ago
2 months ago
/* ✅ Responsive completo */
2 months ago
@media (max-width: 768px) {
2 months ago
.headActions { width: 100%; min-width: 0; justify-content: flex-start; }
.headSpace {
2 months ago
width: 100%;
2 months ago
display: grid;
grid-template-columns: 1fr;
gap: 10px;
2 months ago
}
2 months ago
.btnSoft, .btnPrimary { width: 100%; }
.scoreMain { font-size: 36px; }
2 months ago
}
@media (max-width: 480px) {
2 months ago
.page { padding: 12px; }
.scoreMain { font-size: 32px; }
.bigProgressMeta { flex-direction: column; align-items: flex-start; }
2 months ago
}
2 months ago
</style>