feat: frontend alumnos sam-pruebas (RUT+clave, rendir y mis resultados)
This commit is contained in:
commit
e6d283e184
26 files changed
+2555
No files matched your search
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import SamShell from "../components/SamShell";
|
||||
import {
|
||||
apiGet,
|
||||
apiPost,
|
||||
apiPut,
|
||||
AUTO_TYPES,
|
||||
parseJSON,
|
||||
useMisCursos,
|
||||
type Asignacion,
|
||||
type Pregunta,
|
||||
type Template
|
||||
} from "../lib/mis";
|
||||
import { useSession } from "../lib/session";
|
||||
|
||||
type Fase = "seleccion" | "rindiendo" | "resultado";
|
||||
|
||||
export default function Rendir() {
|
||||
const { user } = useSession();
|
||||
const { cursos } = useMisCursos();
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [asignaciones, setAsignaciones] = useState<(Asignacion & { cursoNombre: string })[]>([]);
|
||||
const [asignacionId, setAsignacionId] = useState("");
|
||||
const [preguntas, setPreguntas] = useState<Pregunta[]>([]);
|
||||
const [respuestas, setRespuestas] = useState<Record<string, string>>({});
|
||||
const [rendicionId, setRendicionId] = useState("");
|
||||
const [fase, setFase] = useState<Fase>("seleccion");
|
||||
const [nota, setNota] = useState<{ ob: number; mx: number; ap: boolean } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
apiGet("/templates").then(setTemplates).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || cursos.length === 0) return;
|
||||
(async () => {
|
||||
const todas: (Asignacion & { cursoNombre: string })[] = [];
|
||||
for (const c of cursos) {
|
||||
try {
|
||||
const as: Asignacion[] = await apiGet(`/asignaciones?origen=${user.origen}&idCursos=${c.idCursos}`);
|
||||
as.forEach((a) => todas.push({ ...a, cursoNombre: c.nombreCurso }));
|
||||
} catch {
|
||||
/* sin asignaciones */
|
||||
}
|
||||
}
|
||||
setAsignaciones(todas);
|
||||
})();
|
||||
}, [user, cursos]);
|
||||
|
||||
const asig = asignaciones.find((a) => a.id === asignacionId);
|
||||
const tpl = templates.find((t) => t.id === asig?.templateId);
|
||||
|
||||
async function iniciar(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (!user || !asig || !tpl) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await apiPost("/rendiciones/iniciar", { asignacionId, idAlumno: user.idAlumno, respuestas: "[]" });
|
||||
setRendicionId(r.id);
|
||||
setPreguntas(await apiGet(`/templates/${tpl.id}/preguntas`));
|
||||
setRespuestas({});
|
||||
setFase("rindiendo");
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function esCorrecta(p: Pregunta, val: string): boolean | null {
|
||||
if (p.tipoPregunta === "multiple-choice") {
|
||||
return val !== "" && Number(val) === Number(parseJSON(p.respuestaCorrecta));
|
||||
}
|
||||
if (p.tipoPregunta === "true-false") {
|
||||
const exp = parseJSON(p.respuestaCorrecta);
|
||||
return val !== "" && (val === "true") === (exp === true || exp === "true");
|
||||
}
|
||||
if (p.tipoPregunta === "short-answer") {
|
||||
const exp = (parseJSON(p.respuestaCorrecta) ?? p.respuestaCorrecta ?? "").toString().trim().toLowerCase();
|
||||
return val.trim() !== "" && val.trim().toLowerCase() === exp;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function entregar() {
|
||||
setError("");
|
||||
const auto = preguntas.filter((p) => AUTO_TYPES.includes(p.tipoPregunta));
|
||||
let ob = 0;
|
||||
const mx = auto.reduce((s, p) => s + p.puntaje, 0);
|
||||
const detalle = preguntas.map((p) => {
|
||||
const v = respuestas[p.id] ?? "";
|
||||
const c = esCorrecta(p, v);
|
||||
if (c === true) ob += p.puntaje;
|
||||
return { preguntaId: p.id, tipo: p.tipoPregunta, respuesta: v, correcta: c };
|
||||
});
|
||||
try {
|
||||
await apiPut(`/rendiciones/${rendicionId}/cerrar`, {
|
||||
puntajeObtenido: ob,
|
||||
puntajeMaximo: mx,
|
||||
respuestas: JSON.stringify(detalle)
|
||||
});
|
||||
const umbral = tpl?.puntajeAprobacion ?? 70;
|
||||
setNota({ ob, mx, ap: mx > 0 ? ob >= (mx * umbral) / 100 : true });
|
||||
setFase("resultado");
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPregunta(p: Pregunta) {
|
||||
const v = respuestas[p.id] ?? "";
|
||||
const set = (val: string) => setRespuestas({ ...respuestas, [p.id]: val });
|
||||
if (p.tipoPregunta === "multiple-choice") {
|
||||
const opts = (parseJSON(p.opciones) as string[]) ?? [];
|
||||
return (
|
||||
<div>
|
||||
{opts.map((o, idx) => (
|
||||
<div className="form-check" key={idx}>
|
||||
<input className="form-check-input" type="radio" name={p.id} checked={v === String(idx)} onChange={() => set(String(idx))} />
|
||||
<label className="form-check-label">{o}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (p.tipoPregunta === "true-false") {
|
||||
return (
|
||||
<div>
|
||||
{(["true", "false"] as const).map((b) => (
|
||||
<div className="form-check form-check-inline" key={b}>
|
||||
<input className="form-check-input" type="radio" name={p.id} checked={v === b} onChange={() => set(b)} />
|
||||
<label className="form-check-label">{b === "true" ? "Verdadero" : "Falso"}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (p.tipoPregunta === "short-answer") {
|
||||
return <input className="form-control" value={v} onChange={(e) => set(e.target.value)} placeholder="Tu respuesta" />;
|
||||
}
|
||||
return <textarea className="form-control" rows={3} value={v} onChange={(e) => set(e.target.value)} placeholder="Tu respuesta (la revisa el profesor)" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SamShell title="Rendir" subtitle={tpl ? tpl.nombre : "Elige una de tus pruebas asignadas."} icon="bx-pencil" activeHref="/rendir">
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
{loading && <div className="spinner-border" role="status" />}
|
||||
|
||||
{fase === "seleccion" && (
|
||||
<form onSubmit={iniciar} className="row g-2">
|
||||
<div className="col-md-8">
|
||||
<label className="form-label">Mi prueba asignada</label>
|
||||
<select className="form-select" value={asignacionId} onChange={(e) => setAsignacionId(e.target.value)} required>
|
||||
<option value="">Seleccione…</option>
|
||||
{asignaciones.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{templates.find((t) => t.id === a.templateId)?.nombre ?? "Prueba"} — {a.cursoNombre}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-4 d-flex align-items-end">
|
||||
<button className="btn btn-success w-100" disabled={!asignacionId}>Iniciar prueba</button>
|
||||
</div>
|
||||
{asignaciones.length === 0 && <p className="text-muted">No tienes pruebas asignadas por ahora.</p>}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{fase === "rindiendo" && (
|
||||
<div>
|
||||
<div className="alert alert-info">{tpl?.nombre} — {tpl?.duracionMin} min — responde y entrega.</div>
|
||||
{preguntas.map((p, i) => (
|
||||
<div className="card mb-2" key={p.id}>
|
||||
<div className="card-body">
|
||||
<p className="mb-2"><strong>{i + 1}.</strong> {p.pregunta} <span className="badge bg-secondary">{p.puntaje} pts</span></p>
|
||||
{renderPregunta(p)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button className="btn btn-primary" onClick={entregar} disabled={preguntas.length === 0}>Entregar prueba</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fase === "resultado" && nota && (
|
||||
<div className={`alert ${nota.ap ? "alert-success" : "alert-warning"}`}>
|
||||
<h4>{nota.ap ? "Aprobado" : "No aprobado"}</h4>
|
||||
<p className="mb-0">Puntaje: {nota.ob}/{nota.mx} (las preguntas manuales las revisa el profesor).</p>
|
||||
<a className="btn btn-outline-secondary mt-2" href="/resultados">Ver mis resultados</a>
|
||||
</div>
|
||||
)}
|
||||
</SamShell>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user