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.

604 lines
16 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!-- PanelResultados.vue (DASH STYLE + RESPONSIVE PRO) -->
<template>
<div class="dashboard-modern">
<a-spin :spinning="cargando" tip="Cargando resultados...">
<div class="section-container">
<div class="page alt">
<!-- Head -->
<div class="head">
<div>
<div class="hTitle">Resultados</div>
<div class="hSub">Resumen claro + detalle por curso.</div>
</div>
<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>
</div>
</div>
<template v-if="resultado">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :lg="10">
<a-card class="card scoreCard" :bordered="false">
<div class="scoreTop">
<div class="chipDone">
<span class="chipDot" />
Calificado
</div>
<div v-if="resultado.orden_merito != null" class="chipRank">
Puesto: <b>#{{ resultado.orden_merito }}</b>
</div>
</div>
<div class="scoreMain">{{ fmt2(resultado.total_puntos) }} /100.00</div>
<div class="scoreLabel">Puntaje total</div>
<div class="bigProgress">
<a-progress :percent="notaPercent" />
<div class="bigProgressMeta"></div>
</div>
<a-alert
class="advice"
show-icon
type="success"
message="Consejo"
:description="consejo"
/>
</a-card>
</a-col>
<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</div>
</a-card>
</a-col>
</a-row>
</a-col>
</a-row>
<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">{{ 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>
</div>
</div>
</a-spin>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { useExamenStore } from '../../store/examen.store'
const route = useRoute()
const router = useRouter()
const examenStore = useExamenStore()
const cargando = ref(false)
const examenId = computed(() => Number(route.params.examenId))
const resultado = computed(() => examenStore.resultado)
const fmt2 = (n) => {
const x = Number(n ?? 0)
return x.toFixed(2)
}
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)))
})
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.'
})
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
}
const parseTotal = (curso) => {
const x = Number(tot?.[curso] ?? 0)
return Number.isFinite(x) ? x : 0
}
const parseIncorrectas = (curso) => {
const x = Number(inc?.[curso] ?? 0)
return Number.isFinite(x) ? x : 0
}
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
})
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
}
}
const volver = () => router.back()
onMounted(async () => {
if (!resultado.value && examenId.value) {
await recalcular()
}
})
</script>
<style scoped>
/* ====== MISMO BACKGROUND QUE DASH ====== */
.dashboard-modern {
position: relative;
padding: 22px 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;
}
.page {
width: 100%;
max-width: 1120px;
margin: 0 auto;
padding: 0;
}
.mt16 { margin-top: 16px; }
.alt { background: transparent; }
/* ====== HEADER ====== */
.head {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
align-items: flex-start;
margin-bottom: 14px;
}
.hTitle {
margin: 0;
font-size: 1.85rem;
font-weight: 900;
color: #0d1b52;
line-height: 1.15;
}
.hSub {
margin-top: 4px;
font-size: 12.5px;
color: var(--ant-colorTextSecondary, #6b7280);
line-height: 1.35;
}
.headActions {
min-width: 260px;
display: flex;
justify-content: flex-end;
}
.btnSoft, .btnPrimary {
border-radius: 12px;
font-weight: 900;
}
/* ====== CARD (igual dash content-card) ====== */
.card {
position: relative;
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);
overflow: hidden;
}
.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.05) 0,
rgba(13, 27, 82, 0.05) 1px,
transparent 1px,
transparent 24px
),
repeating-linear-gradient(
to bottom,
rgba(13, 27, 82, 0.05) 0,
rgba(13, 27, 82, 0.05) 1px,
transparent 1px,
transparent 24px
);
opacity: 0.35;
}
.card :deep(.ant-card-body),
.card :deep(.ant-card-head) {
position: relative;
z-index: 1;
}
.card :deep(.ant-card-body) { padding: 16px; }
.scoreCard :deep(.ant-card-body) { padding: 18px; }
/* ====== SCORE ====== */
.scoreTop {
display: flex;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 10px;
}
.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;
}
.chipDot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--ant-colorPrimary, #1677ff);
}
.scoreMain {
font-size: 44px;
font-weight: 950;
color: #111827;
line-height: 1;
}
.scoreLabel {
margin-top: 4px;
font-size: 12px;
color: #6b7280;
font-weight: 900;
}
.bigProgress { margin-top: 12px; }
.advice { margin-top: 12px; border-radius: 14px; }
/* ====== KPIs ====== */
.kpi .kpiK, .kpiWide .kpiK, .noteCard .kpiK {
font-size: 12px;
color: #6b7280;
font-weight: 900;
}
.kpi .kpiV, .kpiWide .kpiV, .noteCard .kpiV {
margin-top: 6px;
font-size: 26px;
font-weight: 950;
color: #111827;
}
.kpiHint {
margin-top: 2px;
font-size: 12px;
color: #6b7280;
}
.kpiWideRow {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.miniCircle {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
}
.miniCircleLabel {
position: absolute;
text-align: center;
}
.miniCircleMain {
font-weight: 950;
font-size: 16px;
color: #111827;
line-height: 1;
}
.miniCircleSub { font-size: 12px; color: #6b7280; }
/* ====== TABLE ====== */
.tableWrap {
width: 100%;
overflow-x: auto;
border-radius: 14px;
}
.tableWrap :deep(.ant-table-container) { overflow-x: auto; }
:deep(.modernTable .ant-table-thead > tr > th) {
background: rgba(13, 27, 82, 0.03);
color: #0d1b52;
font-weight: 900;
}
.courseCell {
display: flex;
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; }
.pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
height: 30px;
padding: 0 10px;
border-radius: 999px;
font-weight: 950;
font-size: 12px;
border: 1px solid rgba(0,0,0,.08);
background: rgba(0,0,0,.03);
color: #111827;
}
.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; }
.empty { margin-top: 12px; }
/* ====== MOBILE: FULL WIDTH, SIN ESPACIOS ====== */
@media (max-width: 768px) {
.dashboard-modern { padding: 0 0 14px; }
.section-container { max-width: none; padding: 0 !important; margin: 0 !important; }
.page { max-width: none; padding: 0; }
.head { padding: 0 16px; margin-bottom: 10px; }
.headActions { width: 100%; min-width: 0; justify-content: flex-start; }
.headSpace {
width: 100%;
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.btnSoft, .btnPrimary { width: 100%; }
/* cards full width */
.card {
border-radius: 0 !important;
border-left: 0 !important;
border-right: 0 !important;
}
.scoreMain { font-size: 36px; }
}
@media (max-width: 480px) {
.head { padding: 0 12px; }
.scoreMain { font-size: 32px; }
}
</style>